# plnnrly REST API v2: full reference > The complete `/api/v2` documentation as Markdown: the guides, then all 228 operations, then the schema pages. It is generated from the same OpenAPI document that `https://developers.plnnrly.com/openapi.json` serves, and that document is the authority for any shape this file summarises. Requests go to your organisation's own address, `https:///api/v2`: the host you sign in to plnnrly on, or your organisation's own domain if it has one. Send `Authorization: Bearer ` with a personal access token or an OAuth access token. `Accept` is optional. The guides cover what holds across the whole API: authentication, paging, filtering, ETags, idempotency, uploads, rate limits and errors. Each operation then opens with its token scope, its permission and its rate cost. Each schema page gives a resource's full field set once, with the operations that return it and how its `List`, `Summary` and `Links` variants differ from the detail object. One operation on its own is at `https://developers.plnnrly.com/md/api/v2/.md`. The index, with every operation's scope on one line, is at `https://developers.plnnrly.com/llms.txt`. --- # Guides --- # Getting started Create a token, find your address, then make your first reads and your first write, one step at a time. This guide takes you from no token to a first write in seven steps. Every request below is a real operation with its own page in the [reference](https://developers.plnnrly.com/en/docs/api/v2/). ### 1. Create a token In plnnrly, open **API access** in the user menu (top right, under your name), choose **Create token** and fill in the form: - **Name**: what the token is for, such as "First steps". - **Scopes**: tick **Read** and **Write**. Ticking **Write** ticks **Read** for you. - **Resources**: leave it on **All resources** for now. - **Expires**: keep the default. Copy the token from the dialog that opens. It is shown once. No **API access** in your user menu? Either the REST API is switched off for your organisation, or your role lacks the **Manage API tokens** permission. See [who may create tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens). Narrow the token to what your integration needs before you ship it. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/). ### 2. Find your base URL ```text https:///api/v2 ``` Replace anything in angle brackets. `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. `` is the token you created in step 1. The **API tokens** page shows this address ready to copy, under **How to connect**. - **Use that exact host.** If your organisation has its own domain, only that domain works. The API never redirects, so another host answers `404`. - **Send the token on every request**, in the header `Authorization: Bearer `. - **The Accept header is optional.** Leave it out, or make sure it includes `application/json` or `*/*`. An `Accept` header with neither answers `406`. ### 3. See what your organisation exposes Each sample comes in three forms: curl, JavaScript and Python. The JavaScript runs on Node 20 or later as an ES module, so save it as a `.mjs` file. The Python uses [httpx](https://www.python-httpx.org/). The JavaScript sets up `base` and `headers` here and the Python sets up `api`; later steps reuse them. ```bash [curl] curl "https:///api/v2" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ```js [JavaScript] const base = 'https:///api/v2' const headers = { Authorization: 'Bearer ' } const discovery = await fetch(base, { headers }).then((response) => response.json()) for (const resource of discovery.data) { console.log(resource.name, resource.path) } ``` ```python [Python] import httpx api = httpx.Client( base_url="https://", headers={"Authorization": "Bearer "}, ) discovery = api.get("/api/v2").json() for resource in discovery["data"]: print(resource["name"], resource["path"]) ``` [Discovery](https://developers.plnnrly.com/en/docs/api/v2/discovery/) answers one entry per resource your organisation has. Each entry looks like this: Response 200: ```jsonc { "data": [ { "name": "Projects", "path": "https://acme.plnnrly.com/api/v2/projects", "item_path": "https://acme.plnnrly.com/api/v2/projects/{project}", "permission_prefix": "projects", "summary_schema": "#/components/schemas/ProjectList", "detail_schema": "#/components/schemas/Project", "operations": ["index", "store", "show", "update", "destroy", "actions.restore"] } // one entry for every other resource, sorted by name ] // then meta and links, as on every collection } ``` The list only leaves out resources of features your organisation does not have, such as billing. It does not look at your token or your role, so a resource listed here can still answer `403` for you. Discovery needs a valid token and no permission, which makes it a good first call. If it answers `200`, your token and your address are right. [GET /me](https://developers.plnnrly.com/en/docs/api/v2/me/get/) is the same kind of call and tells you which user the token acts as. The answer may be cached for five minutes. ### 4. Read a collection ```bash [curl] curl -G "https:///api/v2/projects" \ -H "Authorization: Bearer " \ --data-urlencode "per_page=25" \ --data-urlencode "filter[status]=active" \ --data-urlencode "sort=-last_activity" \ --data-urlencode "include=tags" ``` ```js [JavaScript] const query = new URLSearchParams({ per_page: '25', 'filter[status]': 'active', sort: '-last_activity', include: 'tags', }) const page = await fetch(`${base}/projects?${query}`, { headers }).then((response) => response.json()) console.log(page.data.length, page.links.next) ``` ```python [Python] page = api.get( "/api/v2/projects", params={ "per_page": 25, "filter[status]": "active", "sort": "-last_activity", "include": "tags", }, ).json() print(len(page["data"]), page["links"]["next"]) ``` You get the standard envelope: `data`, `meta` and `links`. A collection returns each row in its `summary` view. Response 200: ```jsonc { "data": [ // up to 25 active projects in the summary view, each with its tags ], "meta": { "page": { "per_page": 25, "next_cursor": "", "prev_cursor": null, "total": 41, "current_page": null, "last_page": null } }, "links": { // self, next and prev: ready-made URLs that repeat your query } } ``` `include=tags` puts each project's tags inside the row. `tags` is a to-many relation, so `per_page` may be at most 25 while you include it; a larger value answers `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter). Each collection's reference page lists the names it accepts. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/). Three things in the response to notice before you build on it: - `links.next` points to the next page. Follow it as it is; never build a cursor yourself. See [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/). - `RateLimit-Policy` states your allowance. Read it instead of hard-coding a number. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). - `ETag` on a page is a weak validator. Send it back as `If-None-Match` and an unchanged page answers `304`. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). ### 5. Read one record ```bash [curl] curl "https:///api/v2/projects/12" \ -H "Authorization: Bearer " ``` ```js [JavaScript] const response = await fetch(`${base}/projects/12`, { headers }) const etag = response.headers.get('ETag') const project = await response.json() console.log(project.data.title, etag) ``` ```python [Python] response = api.get("/api/v2/projects/12") etag = response.headers["ETag"] project = response.json() print(project["data"]["title"], etag) ``` A single read returns the `detail` view, with every field, without you asking for it. Response 200: ```jsonc { "data": { "id": 12, "title": "Portal rebuild", "status": "active", // every other field of the detail view } } ``` The `ETag` here is a strong validator. Keep it if you plan to change the record: send it back as `If-Match`, and the write is refused with `412` if someone changed the record in the meantime. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). ### 6. Write something ```bash [curl] curl "https:///api/v2/worklogs" \ -X POST \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c0a52-3e8b-4b7a-9d55-0c1e2f3a4b5c" \ -d '{ "project_id": 12, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding" }' ``` ```js [JavaScript] const idempotencyKey = crypto.randomUUID() const written = await fetch(`${base}/worklogs`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ project_id: 12, date: '2026-10-14', minutes: 150, description: 'Reviewed the invoice export and fixed the rounding', }), }).then((response) => response.json()) console.log(written.data.id, written.meta?.warnings ?? []) ``` ```python [Python] import uuid idempotency_key = str(uuid.uuid4()) written = api.post( "/api/v2/worklogs", headers={"Idempotency-Key": idempotency_key}, json={ "project_id": 12, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding", }, ).json() print(written["data"]["id"], written.get("meta", {}).get("warnings", [])) ``` Response 201: ```jsonc { "data": { "id": 5120, "date": "2026-10-14", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding", "project_id": 12, // every other field of the detail view } } ``` Five rules apply to every write in this API: 1. **Send JSON.** Every body goes with `Content-Type: application/json`. There is no form encoding and no multipart; anything else answers `415`. File contents go through a separate upload step, see [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). 2. **Request bodies are closed.** An unknown key answers `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) with the code `prohibited` on a pointer to that key. Its message lists the fields you may write. 3. **Send an idempotency key with every POST.** It goes in the `Idempotency-Key` header. Generate it before you send, store it, and reuse it when you retry. [POST /worklogs](https://developers.plnnrly.com/en/docs/api/v2/worklogs/create/) is one of 3 operations that require one, and answers `400` [idempotency-key-required](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-required) without it. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). 4. **A write answers with the record in its detail view**, so you do not need a follow-up `GET`. 5. **Check for warnings after every write.** A field you may not set can be dropped instead of refused, and `meta.warnings` says so. For example, `is_overtime` without the `times::write_overtime` permission is left out, and the rest of the write is applied. The answer's `meta` then carries: Response: ```json { "warnings": [ { "code": "input_ignored", "detail": "The field \"is_overtime\" was ignored: writing it requires the times::write_overtime permission. The rest of the write was applied.", "pointer": "/is_overtime" } ] } ``` See [permissions that change what a call does](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/#permissions-that-change-what-a-call-does). To change a record, send a `PATCH` with only the fields you change. Add `If-Match` so you cannot overwrite a change you have not seen. ### 7. Handle the failures Every error response, status `400` and up, is a problem document, as defined in RFC 9457. **Switch on the problem type, never on the status code alone.** A `403`, for example, can be a token problem, a role problem or a rule on the record, and each has a different fix. Response 403: ```json { "type": "https://developers.plnnrly.com/api/v2/problems/insufficient-token-scope", "title": "Insufficient token scope", "status": 403, "detail": "This token does not carry an ability that covers this endpoint.", "instance": "/api/v2/drive-documents/91", "request_id": "01K7F3Q9YV3PZ2N8T1E6M0XR4S", "required_ability": [ "api:read:project_drive", "api:read:customer_drive", "api:read:organization_drive", "api:read:user_drive" ] } ``` The ones you will meet first while building: | Problem | What to do | | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope) | Create a token that carries one of the abilities in `required_ability`. | | `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission) | The user's role lacks the permission in `required_permission`. No token can work around it; a role change can. | | `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter) | Read the `errors` array. Each entry names the parameter and the fault. | | `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) | Read the `errors` array. Each entry points to the field and says what is wrong with it. | Log `request_id` with every failure. It is the id we look up in our logs, so quote it when you [contact support](https://www.plnnrly.com/en/support). Every problem type, with its fix, is in [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/). ## Try it safely There is no sandbox organisation, so every write changes real data. - Start with a token that has only **Read**. - When you start writing, create a **Write** token narrowed to the resources you need, and try your writes on a test project. - Keep **Delete** out of every token that does not need it. A project with logged time cannot be deleted. To remove a test project afterwards, delete its test worklogs first. ## What to read next | If you are | Read | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Logging time from a script | [Log time from a script](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/log-time/) | | Moving work items through a workflow | [Move work items through the workflow](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/work-item-workflow/) | | Learning how the resources relate | [How the data fits together](https://developers.plnnrly.com/en/docs/api/v2/guides/data-model/) | | Handling dates, durations and amounts | [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/) | | Exporting a whole collection | [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/) | | Narrowing what you fetch | [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/) | | Fetching less, or more, per row | [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/) | | Writing while people work in plnnrly | [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/) | | Attaching files | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Keeping your own copy of the data | [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/) | | Getting ready for production | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) and [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) | | Building an app other people install | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | Or browse the [reference](https://developers.plnnrly.com/en/docs/api/v2/): 228 operations, each with the token scope and the permission it needs. --- # Authentication Every request carries a bearer token, sent to your organisation's own address and acting as one user. Here is how it works and how it fails. Every request carries a bearer token in the `Authorization` header, and nothing else proves who it is for. There is no key pair, no session cookie and no request signing. ```bash curl "https:///api/v2/projects" \ -H "Authorization: Bearer " ``` `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. There are two kinds of token: | Token | For | How you get one | | ------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | **Personal access token** | Scripts, servers and integrations you run yourself. | You create it in plnnrly. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/). | | **OAuth access token** | Apps that other people install and that act for whoever signs in. | The app signs the person in. See [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). | Both carry the same read and write abilities. Three differences: an OAuth token never carries the destroy class, it cannot upload file contents (see [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/)), and its user's **Manage API tokens** permission is checked on every request. Everything else on this page applies to both. ## The host is part of the credential The API is served only on your organisation's own address: `https:///api/v2`. - There is no shared API host, and no organisation id in the path or in a header. The host is what selects your organisation. - A token works only on the organisation it was created in. On another organisation's host it answers `401`. - If your organisation has its own domain, only that exact domain serves the API. Any other host, including the `www` or bare variant of that domain, answers `404` with "No API is served on this host." - The API never redirects, so following redirects gains a client nothing. Use the address shown under **How to connect** on the **API tokens** page. Responses carry a `Vary` header that includes `Authorization`, so a cache between you and plnnrly cannot serve one caller's rows to another. ## A token acts as a user A token belongs to one user: the person who created it, or the person who signed in to the app. Every request runs as that user, so: 1. **The token sees what the user sees, and no more.** Project membership, row visibility and the rules on each record apply exactly as they do in plnnrly. 2. **A role change takes effect at once.** Permissions are read again on every request. Taking a permission away from a role narrows every token its users hold, without touching the tokens. 3. **Everything runs as that user.** `POST /worklogs` logs time for the token's owner, and filters that accept `me`, such as `filter[user_id]=me`, resolve to them. [GET /me](https://developers.plnnrly.com/en/docs/api/v2/me/get/) returns that user to any API token and needs no permission, so a client can show who it is connected as. Choose whose token an integration uses with care. It can do exactly what that person can, and it changes when their roles change. ## Sign in with OAuth An app that other people install should not ask them to paste a token. Instead it signs them in with OAuth 2.0 (the authorization code flow with PKCE) and receives an access token that it renews itself. The app needs no secret and no approval from plnnrly in advance: it registers itself on the organisation's host. [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) walks through every step and ends with a complete, runnable example. ## OAuth tokens cannot delete OAuth never offers a destroy-class scope: neither `api:destroy` nor any `api:destroy:{prefix}`. Every `DELETE` on `/api/v2` needs that class, so an app signed in with OAuth cannot delete anything through the REST API, not even a soft delete. Deleting needs a personal access token with **Delete**. AI assistants are the exception. Over MCP, an assistant can delete through its `api_write` tool when the organisation allows destructive actions and the user confirms the delete. That does not apply when the same sign-in also asked for `api:` scopes. See [Permissions and safety](https://developers.plnnrly.com/en/docs/mcp/permissions-and-safety/). ## The order of checks Every request passes these checks in this order. The first one that fails decides the answer. 1. **The REST API is switched off** for the organisation: `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found). 2. **The bearer token** is missing or not valid: `401` [unauthenticated](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated). 3. **OAuth tokens only:** the user lacks **Manage API tokens**: `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission). 4. **The token's scope** does not cover the operation: `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope), with `required_ability` listing what would have worked. 5. **The operation belongs to a feature** your organisation does not have, such as billing: `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found). 6. **Writes only, the body:** an unknown or invalid field answers `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed). A field that needs a permission of its own answers `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission) naming that field. 7. **The role lacks the operation's permission:** `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission), with `required_permission`. On a route with a record in it, this is `404` [resource-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#resource-not-found) instead when the role cannot read that kind of record at all, so the answer does not reveal whether the record exists. 8. **The record is not visible** to the user: `404` [resource-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#resource-not-found). 9. **The record's own rules** refuse the user: `403` [forbidden-by-policy](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#forbidden-by-policy), or `404` under the same rule as step 7. 10. **The record's state** does not allow the change: `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict). Steps 4 and 7 are separate fixes. A scope failure needs a different token; a permission failure needs a role change. Every operation page states the scope and the permission it needs, and [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/) explains both. Other answers can come before or in between: `404` on a host that serves no API, `503` [tenant-unavailable](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#tenant-unavailable) while the organisation is being set up, suspended or archived, `429` once you exceed a [rate limit](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/), `400` for an unknown query parameter or a missing `Idempotency-Key`, and `404` [resource-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#resource-not-found) right after step 4 when the record id does not exist at all. [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) lists them all. ## When authentication itself fails Every authentication failure answers `401` [unauthenticated](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated), whatever the cause: - no `Authorization` header, or a malformed one - a token that is unknown or revoked - a token that has expired - a token from another organisation Every `401` carries the header `WWW-Authenticate: Bearer realm="plnnrly-api"`. An expired token answers `unauthenticated` too. The error catalogue also lists [token-expired](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#token-expired), but it is not sent today, so treat the two alike. A personal access token expires within 12 months, so plan for it. Watch the **Expires** column on the **API tokens** page, and [roll](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#roll-a-token) or replace a token before that date. An OAuth app renews its own access token with its refresh token. ## When the API is switched off An administrator can switch the REST API off for the whole organisation, under **Settings › Administration › MCP & API**. See [For administrators](https://developers.plnnrly.com/en/docs/administration/) for who may do that. While it is off: - Every `/api/v2` request answers `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found) with the detail "The REST API is not enabled for this organisation.", whatever the token. - The OAuth endpoints answer `404`, so apps cannot sign anyone in. - **API access** disappears from the user menu. - MCP is switched off too. Switching the API back on does not switch MCP back on. - Tokens are kept. They work again once the API is back on, until they expire. ## MCP access is a different family AI assistants connect to the [MCP server](https://developers.plnnrly.com/en/docs/mcp/) through the same sign-in, with the `mcp:use` scope. Older personal MCP tokens keep working there. - A token that holds only `mcp:use` cannot call `/api/v2`: it answers `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope). - A token without `mcp:use` cannot open an MCP session. - An app that asks for `mcp:use` and `api:` scopes in one sign-in is held to those `api:` scopes over MCP as well, and its user's **Manage API tokens** permission is checked on every call. Because OAuth offers no destroy class, such an app cannot delete over MCP either. The consent screen lists every scope an app asks for, so the user sees what they approve. ## Browsers and CORS `/api/v2` accepts browser requests only from this documentation site, for the **Try it** panel on operation pages. A single-page app on any other origin cannot call the API from the browser, and neither can it call the OAuth token endpoint. Call the API from your own server instead, and keep the token there. ## Related - [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/): creating, narrowing, rolling and revoking personal access tokens. - [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/): letting people sign in to your app. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): what each scope and permission covers. - [Getting started](https://developers.plnnrly.com/en/docs/api/v2/guides/getting-started/): your first requests, step by step. --- # Sign in with OAuth Let people sign in to your desktop or command-line app with their plnnrly account, instead of pasting a token. OAuth 2.0 with PKCE, step by step. An app that other people install can sign each person in with their own plnnrly account. The person approves the app once, and the app gets an access token that it renews itself. Nobody copies a token, and the app never sees a password. This guide walks through every step and ends with [a complete example](#a-complete-example) you can run. ## Who this is for - **Desktop, command-line and native apps** that other people install. They receive the sign-in on a loopback address on the person's own computer. - **Not web apps with their own callback address.** An app cannot register an `https` address of its own; see [redirect addresses](#redirect-addresses). - **Not apps that run in the browser.** The token endpoint accepts no cross-origin requests, so a browser app cannot finish the sign-in. - **Not your own scripts and servers.** For those, a [personal access token](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/) is simpler. ## Before you start - The organisation has the REST API switched on. It is on unless an administrator switched it off; see [For administrators](https://developers.plnnrly.com/en/docs/administration/#switches). - The person who signs in holds the **Manage API tokens** permission. In a new organisation the **Admin** role has it. See [who may create tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens). - Every address in this guide is on the organisation's own host, `https://`. `` is the address people sign in to plnnrly on, such as `acme.plnnrly.com`, or the organisation's own domain. Each organisation is separate, so your app asks the person for this address first. ## 1. Discover the endpoints ```bash curl "https:///.well-known/oauth-authorization-server" ``` The answer names everything the rest of this guide uses: | Field | Value | | ---------------------------------- | -------------------------------------------------------------------------- | | `authorization_endpoint` | `https:///oauth/authorize`: where you send the person's browser. | | `token_endpoint` | `https:///oauth/token`: where you exchange and refresh tokens. | | `registration_endpoint` | `https:///oauth/register`: where your app registers itself. | | `revocation_endpoint` | `https:///oauth/revoke`: where you sign out. | | `code_challenge_methods_supported` | `["S256"]` | | `scopes_supported` | Every scope you may ask for. | `https:///.well-known/oauth-protected-resource/api/v2` describes the API itself and lists the scopes it accepts. A `404` here means the organisation has the REST API switched off. ## 2. Register your app ```bash curl "https:///oauth/register" \ -X POST \ -H "Content-Type: application/json" \ -d '{ "client_name": "Acme Timesheets", "redirect_uris": ["http://127.0.0.1:53682/callback"] }' ``` The answer is `201`: ```json { "client_id": "9e3f2c71-5b8a-4d06-a1f4-7c2e9b5d8a13", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "redirect_uris": ["http://127.0.0.1:53682/callback"], "scope": "mcp:use", "token_endpoint_auth_method": "none" } ``` - **Your app is a public client.** It gets no client secret, and `token_endpoint_auth_method` is `none`. - **The client name is what people see.** `client_name` shows on the consent screen and under **Connected apps**. Use the name they know your app by. - **Ignore the scope in the answer.** Registration always answers with `"scope": "mcp:use"`, whatever your app will ask for. You choose the scopes when you send the person to sign in. - **A refused redirect address** answers `400` with `"error": "invalid_redirect_uri"`. - **Registration is limited** to 5 requests per minute per IP address and 30 per minute per organisation. Register once per host and keep the `client_id`. ## Redirect addresses After the person approves, their browser goes to your redirect address with a code. Listen on a loopback address while the sign-in runs. | Redirect address | Accepted | | ------------------------------------------- | ------------------------------------------------------------------------------------- | | `http://127.0.0.1:{port}/path` | Yes. The port may differ at every sign-in, so listen on any free port. | | `http://[::1]:{port}/path` | Yes, in the same way. | | `http://localhost:{port}/path` | Only with exactly the port you registered. | | A custom scheme, such as `myapp://callback` | No. The only custom scheme accepted is Cursor's own. | | An `https` address of your own | No. The only `https` addresses accepted are those of the AI clients plnnrly supports. | On `127.0.0.1` and `[::1]` everything except the port must match the address you registered. On any other address the whole address must match exactly. A refused address answers `invalid_redirect_uri` when you register. An address that does not match during sign-in answers `invalid_client`. ## Keep the `client_id` per host A `client_id` works on one organisation's host only. Store it per host, and register separately for each organisation your app connects to. A registration is deleted overnight once it is more than a day old and nothing issued to it is left. That happens to: - an app that never completed a sign-in - an app that every person disconnected - an app nobody used for a month: after 30 idle days its refresh token stops working, and from about day 32 the registration is gone too When the token endpoint answers `invalid_client`, or the browser shows `invalid_client` during sign-in, register again and start a new sign-in. ## 3. Send the person to sign in Create a random `code_verifier` of 43 to 128 characters and keep it until step 4. The `code_challenge` is its SHA-256 hash, base64url-encoded without padding. Then open the person's browser at the authorization endpoint with these parameters: | Parameter | Value | | ----------------------- | ----------------------------------------------------------------------- | | `response_type` | `code` | | `client_id` | From registration. | | `redirect_uri` | The address you listen on. | | `scope` | The scopes you need, separated by spaces, such as `api:read api:write`. | | `state` | A random value. Check that it comes back unchanged. | | `code_challenge` | The hash of your `code_verifier`. | | `code_challenge_method` | `S256` | PKCE is required. **Always send the scope parameter.** Without `scope` the sign-in asks for `mcp:use`, the scope for AI assistants, which cannot call `/api/v2`. While MCP is switched off, a sign-in without `scope` answers `404`. Ask for `api:read` and `api:write`, or narrow them to a resource, such as `api:read:projects` and `api:write:projects`. `scopes_supported` lists every scope you may ask for, and [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/) explains what each one covers. There is no destroy class over OAuth: asking for `api:destroy` fails with `invalid_scope`. ## What the person sees If the person is not signed in to plnnrly on that host, they sign in first. Then the consent screen shows: - the title "Connect Acme Timesheets", and "Acme Timesheets wants to access Acme on your behalf." - **Signed in as**, with their name and email address - the scopes you asked for, such as "Read: All resources" or "Write: Projects" - "It acts as you: it can see and change what your account can, and nothing more." - a warning that the app can also delete records, when you ask for `mcp:use` and the organisation allows destructive actions - the host their browser goes back to, such as "After you approve you are sent back to 127.0.0.1." The buttons are **Approve** and **Cancel**. **Approve** sends the browser to your redirect address with `code` and `state`. **Cancel** sends it there with `error=access_denied`. A person who already approved the same scopes for your app, and still holds a live access token from it, is sent straight back without seeing the screen. The sign-in is refused when: - the person's role lacks **Manage API tokens**: `403` "Your role does not allow connecting apps to the REST API." - the person is in a plnnrly support session: `403` - you ask for `mcp:use` while MCP is switched off for the organisation: `404` ## 4. Exchange the code ```bash curl "https:///oauth/token" \ -X POST \ -d grant_type=authorization_code \ -d client_id= \ -d redirect_uri=http://127.0.0.1:53682/callback \ -d code_verifier= \ -d code= ``` The token endpoint takes form-encoded bodies. It answers: ```json { "token_type": "Bearer", "expires_in": 3600, "access_token": "", "refresh_token": "" } ``` Send the access token as the bearer token on `/api/v2`. It lasts one hour. The token endpoint allows 120 requests per minute per organisation and IP address, enough for many apps behind one office connection. The discovery documents and the revocation endpoint allow 30 requests per minute per organisation and IP address. ## 5. Refresh Once the access token has expired, `/api/v2` answers `401` [unauthenticated](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated). Trade the refresh token for a new pair: ```bash curl "https:///oauth/token" \ -X POST \ -d grant_type=refresh_token \ -d client_id= \ -d refresh_token= ``` - **Each refresh token works once.** The answer carries a new one, and the old one stops working, so store the new one straight away. - **A refresh token lasts 30 days**, counted from the last refresh. An app used at least once every 30 days stays signed in. - **After 30 idle days** the refresh answers `invalid_grant`, and the person signs in again. ## 6. Sign out ```bash curl "https:///oauth/revoke" \ -X POST \ -d token= \ -d token_type_hint=refresh_token \ -d client_id= ``` Revoking either token disconnects the app for that person: every token it holds for them stops working. The endpoint answers `200` even for a token it does not recognise, as [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) requires. People can also disconnect an app themselves: they open **API access** in the user menu and choose **Revoke** next to the app under **Connected apps**. See [connected apps](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#connected-apps). ## What an OAuth app cannot do - **Delete anything.** Every `DELETE` needs the destroy class, which OAuth never offers. Deleting needs a personal access token with **Delete**. - **Upload file contents.** The upload step accepts personal access tokens only, so an OAuth token gets `401` there. See [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). - **Reach more over MCP.** If the same sign-in also asks for `mcp:use`, the app is held to its `api:` scopes there too. See [MCP access](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/#mcp-access-is-a-different-family). The person's **Manage API tokens** permission is checked on every request. If an administrator takes it away, the app's calls answer `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission) until the permission is given back. ## A complete example This script signs a person in and prints their name. It opens a loopback listener on a free port, registers, creates the PKCE pair, opens the browser, exchanges the code and calls [GET /me](https://developers.plnnrly.com/en/docs/api/v2/me/get/). Save the JavaScript as `sign-in.mjs` and run it with Node 20 or later, with no packages to install. Save the Python as `sign_in.py` and run it with Python 3.10 or later after `pip install httpx`. Pass the address to sign in to: ```bash node sign-in.mjs acme.plnnrly.com python sign_in.py acme.plnnrly.com ``` ```js [JavaScript] import { spawn } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' import { createServer } from 'node:http' const host = process.argv[2] if (!host) { console.error('Usage: node sign-in.mjs ') process.exit(1) } const base = `https://${host}` const scope = 'api:read' async function call(url, init = {}) { const response = await fetch(url, { ...init, headers: { Accept: 'application/json', ...init.headers } }) const text = await response.text() if (!response.ok) { throw new Error(`${response.status} from ${url}: ${text}`) } return JSON.parse(text) } function openBrowser(url) { const [command, args] = { darwin: ['open', [url]], win32: ['rundll32', ['url.dll,FileProtocolHandler', url]], }[process.platform] ?? ['xdg-open', [url]] spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref() } // 1. Listen on a free port on the loopback address. const server = createServer() await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const redirectUri = `http://127.0.0.1:${server.address().port}/callback` // 2. Discover the endpoints. const metadata = await call(`${base}/.well-known/oauth-authorization-server`) // 3. Register. A real app stores client_id per host and registers again on invalid_client. const { client_id: clientId } = await call(metadata.registration_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'Acme Timesheets', redirect_uris: [redirectUri] }), }) // 4. Create the PKCE pair and send the person to sign in. const verifier = randomBytes(32).toString('base64url') const challenge = createHash('sha256').update(verifier).digest('base64url') const state = randomBytes(16).toString('base64url') const authorizeUrl = new URL(metadata.authorization_endpoint) authorizeUrl.search = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope, state, code_challenge: challenge, code_challenge_method: 'S256', }) const callback = new Promise((resolve) => { server.on('request', (request, response) => { const url = new URL(request.url, redirectUri) if (url.pathname !== '/callback') { response.writeHead(404).end() return } response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) response.end('You can close this window and go back to the app.') resolve(url.searchParams) }) }) console.log(`Opening your browser. If it does not open, visit:\n${authorizeUrl}`) openBrowser(authorizeUrl.href) const params = await callback server.close() if (params.get('state') !== state) { throw new Error('The state does not match. Start the sign-in again.') } if (params.has('error')) { throw new Error(`The sign-in was refused: ${params.get('error')}`) } // 5. Exchange the code for tokens. const tokens = await call(metadata.token_endpoint, { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, redirect_uri: redirectUri, code_verifier: verifier, code: params.get('code'), }), }) // 6. Call the API as the person who signed in. const me = await call(`${base}/api/v2/me`, { headers: { Authorization: `Bearer ${tokens.access_token}` }, }) console.log(`Signed in as ${me.data.name}`) ``` ```python [Python] import base64 import hashlib import secrets import sys import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlencode, urlparse import httpx if len(sys.argv) != 2: sys.exit("Usage: python sign_in.py ") base = f"https://{sys.argv[1]}" scope = "api:read" class Callback(BaseHTTPRequestHandler): params = None timeout = 5 def do_GET(self): url = urlparse(self.path) if url.path != "/callback": self.send_response(404) self.end_headers() return Callback.params = {key: values[0] for key, values in parse_qs(url.query).items()} self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(b"You can close this window and go back to the app.") def log_message(self, *args): pass def call(response): if response.is_error: sys.exit(f"{response.status_code} from {response.url}: {response.text}") return response.json() # 1. Listen on a free port on the loopback address. server = HTTPServer(("127.0.0.1", 0), Callback) redirect_uri = f"http://127.0.0.1:{server.server_port}/callback" with httpx.Client(headers={"Accept": "application/json"}) as client: # 2. Discover the endpoints. metadata = call(client.get(f"{base}/.well-known/oauth-authorization-server")) # 3. Register. A real app stores client_id per host and registers again on invalid_client. client_id = call(client.post( metadata["registration_endpoint"], json={"client_name": "Acme Timesheets", "redirect_uris": [redirect_uri]}, ))["client_id"] # 4. Create the PKCE pair and send the person to sign in. verifier = secrets.token_urlsafe(32) digest = hashlib.sha256(verifier.encode()).digest() challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() state = secrets.token_urlsafe(16) authorize_url = metadata["authorization_endpoint"] + "?" + urlencode({ "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "scope": scope, "state": state, "code_challenge": challenge, "code_challenge_method": "S256", }) print(f"Opening your browser. If it does not open, visit:\n{authorize_url}") webbrowser.open(authorize_url) while Callback.params is None: server.handle_request() server.server_close() params = Callback.params if params.get("state") != state: sys.exit("The state does not match. Start the sign-in again.") if "error" in params: sys.exit(f"The sign-in was refused: {params['error']}") # 5. Exchange the code for tokens. tokens = call(client.post(metadata["token_endpoint"], data={ "grant_type": "authorization_code", "client_id": client_id, "redirect_uri": redirect_uri, "code_verifier": verifier, "code": params["code"], })) # 6. Call the API as the person who signed in. me = call(client.get( f"{base}/api/v2/me", headers={"Authorization": f"Bearer {tokens['access_token']}"}, )) print(f"Signed in as {me['data']['name']}") ``` A real app does three things this script leaves out. It stores the `client_id` per host instead of registering on every run. It keeps the refresh token in the system's credential store and [refreshes](#refresh) when a call answers `401`. And it asks for only the scopes it needs. ## Related - [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/): how tokens are sent and the order of checks. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): what each scope covers. - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/): every limit in one place, the OAuth ones included. --- # API tokens Create the personal access tokens your scripts and integrations use, narrow them to what they need, and roll or revoke them in time. A personal access token lets your own scripts and integrations call `/api/v2` as you. It is shown once, it always expires, and it never grants more than you may do yourself. Building an app that other people install? Let it [sign them in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) instead of asking them for a token. ## Create a token 1. In plnnrly, open **API access** in the user menu (top right, under your name). The page that opens is called **API tokens**. 2. Choose **Create token**. 3. Fill in the form. The fields are explained below. 4. Copy the token from the **Your new API token** dialog. It is shown once: after you close the dialog, nobody can see it again. If you lose it, [roll](#roll-a-token) the token or revoke it and create another. The **How to connect** block at the top of the page shows your **Base URL**, ready to copy, and how to send the token: in the header `Authorization: Bearer `. | Field | What to fill in | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Name** | Required. Name it after the integration, not after yourself: it is what you will read in the token list a year from now. | | **Scopes** | **Read**, **Write** or **Delete**. **Write** includes **Read**, and **Delete** includes both, so ticking one ticks and locks the ones it includes. Only the scopes your role can use are offered. | | **Resources** | Optional. Leave it on **All resources**, or pick the resources this integration touches. Only the resources your role can use are listed. | | **Expires** | Required. It defaults to 90 days from today and can be at most 12 months away. | The token list shows each token's **Name**, **Scopes**, **Created**, **Expires** and **Last used** date. **Last used** is the quickest way to find tokens that nothing uses any more. ## Scopes and resources **Scopes** and **Resources** together make the abilities the token carries. Ticking **Read** and **Write** with the resources **Projects** and **Budgets** creates four: ```text api:read:projects api:read:budgets api:write:projects api:write:budgets ``` On **All resources**, the token carries the unnarrowed abilities instead, such as `api:read` and `api:write`, which cover every resource. **Delete** is stored as `destroy`, so its abilities read `api:destroy:projects` or `api:destroy`. **Resources** shows the permission group names you also see on the **Roles** page, not the prefixes in the abilities. This is what each one covers: | Resources option | Prefix | Covers | | ---------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Projects | `projects` | `/projects`, `/project-links` | | Budgets | `budgets` | `/budgets` | | Budget groups | `budget_groups` | `/budget-groups` | | Work types | `work_types` | `/work-types` | | Workflows | `workflows` | `/workflows`, `/workflow-statuses`, `/workflow-transitions` | | Customers | `customers` | `/customers`, `/contacts`, `/contact-roles` | | Customer categories | `customer_categories` | `/customer-categories` | | Sales invoices | `sales_invoices` | `/sales-invoices`, `/sales-invoice-rows` | | Invoices | `invoices` | `/invoices`, the legacy per-project invoices from before sales invoices | | Customer subscriptions | `customer_subscriptions` | `/customer-subscriptions`, `/subscription-items` | | Subscription types | `subscription_types` | `/subscription-types`, `/subscription-type-items` | | Subscription products | `subscription_products` | `/subscription-products` | | Company branches | `company_branches` | `/company-branches` | | Users | `users` | `/users` | | Project workspace | `project_management` | `/work-items` and everything under it, `/subtasks`, `/work-item-links`, `/labels`, `/sprints`, `/todos`, and the comments and uploads of work items | | Kanban boards | `kanban` | `/tasks`, `/projects/{project}/tasks` | | Time entries | `times` | `/worklogs` | | Worklog overview | `hours` | `/timers` | | Project drive | `project_drive` | Drive items owned by a project | | Customer drive | `customer_drive` | Drive items owned by a customer | | Organisation drive | `organization_drive` | Drive items owned by the organisation | | User drive | `user_drive` | Drive items owned by a user | | Absences | `absences` | `/absences` | | Public holidays | `holidays` | `/holidays` | | Free days | `free_days` | `/free-days` | | Daily check-ins | `checkins` | `/checkins` | | Furlough requests | `furlough` | `/furlough-balance-mutations` | | Agenda | `events` | `/events` | The four drive options cover `/drive-items`, `/drive-folders`, `/drive-documents` and `/notes`, plus the comments, files and uploads of drive items, each for the items with that owner. > **Warning:** > **Time entries** and **Worklog overview** are often mixed up. Logged hours, `/worklogs`, are **Time entries** (`times`). Running timers, `/timers`, are **Worklog overview** (`hours`). A token narrowed to **Worklog overview** cannot log time: `POST /worklogs` answers `403` insufficient-token-scope. Two rules limit what a token can carry: - **The form only offers what your role reaches.** **Scopes** lists the classes you can use on at least one resource, and **Resources** lists the resources you can use with at least one class, which is why two colleagues can see different options. A combination your role cannot use, such as **Write** on a resource you may only read, can still be created, but it never lets the token do more than your role allows. - **There is no wildcard.** No token ever carries "everything", and an unknown resource is dropped rather than stored. Narrow each token to what its integration does. A nightly export needs **Read** on two resources. Giving it **Delete** on everything because that was one click less turns a leaked token from an incident into a disaster. [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/) lists every ability and the operations it covers. ### Narrowing to your own time or check-ins The worklogs, timers and check-ins you file yourself need no permission. Reading them back does, even your own: `hours::read` for worklogs and timers, `checkins::read` for check-ins. **Resources** lists **Worklog overview** and **Daily check-ins** only with those permissions, and **Time entries** with `hours::read` or an extra time permission such as `times::write_overtime`. Without those, you cannot narrow a token to your own time or check-ins. Create a **Write** token on **All resources** instead. It still reaches only what your role allows. ## Expiry Every token expires. There is no option for a token that never expires. - **Expires** defaults to 90 days from today and can be at most 12 months away. - An expired token answers `401` [unauthenticated](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated), the same as a revoked one. - The token list highlights an **Expires** date that has passed. Plan for the date before it arrives: [roll](#roll-a-token) the token, or create its replacement, deploy it and revoke the old one. ## Roll a token **Roll** replaces a token's secret and gives it a new expiry date. 1. In the token list, choose **Roll** on the token. 2. Pick the new **Expires** date. It starts at the old date while that is still in the future. 3. Copy the new secret. It is shown once. What happens: - The token keeps its name and its abilities. - The old secret stops working at once, so your clients get `401` until you give them the new one. - Rolling also renews a token that has already expired. - Rolling works when you already hold ten tokens. The one exception is rolling an expired token while you hold ten other live ones. Which to use: - **Roll** when a short gap is acceptable. - For no gap at all: create a new token, deploy it, then revoke the old one. ## Revoke a token **Revoke** deletes a token at once. Every client that uses it gets `401` [unauthenticated](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unauthenticated) from its next request. There is no grace period. Revoking is not the only way to narrow access. Permissions are read again on every request, so taking a permission away from a role narrows every token its users hold, at once. If a token can do more than it should, a role change may be the better fix. ## The ten-token cap You can hold at most ten live API tokens. Expired tokens and connected apps do not count. At the cap, submitting **Create token** shows "You already have ten active API tokens. Revoke one before you create another." The cap exists because of the [rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). Each token has an allowance of its own, but all tokens in the organisation also share one organisation allowance, so more tokens never add room beyond that ceiling. ## Who may create tokens Creating, rolling and revoking tokens needs the **Manage API tokens** permission. The same permission decides whether you may connect an app to the REST API with [OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). Connecting an AI assistant over MCP does not need it. - In a new organisation the **Admin** role holds it from the start. If nobody sees **API access**, someone who manages roles grants it as below. - To give it to a role, open **HRM › User Management › Roles**, edit the role, and tick **Manage API tokens** under **API tokens** on the **Settings** tab. - Taking it away stops the user's OAuth apps at once: their calls answer `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission). The user's personal tokens keep working until they expire, but the user can no longer see, roll or revoke them. To clean them up, give the permission back first. The organisation's switch comes first. While the REST API is switched off, **API access** is hidden and every token answers `404`. See [when the API is switched off](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/#when-the-api-is-switched-off). ## Connected apps Apps that signed you in with [OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) are listed under **Connected apps** on the same page, with the host they send you back to. The section appears once at least one app is connected. - You approve an app once. From then on it renews its own access, so there is nothing to copy and no expiry date to plan for. - **Revoke** disconnects an app from the API at once, and it has to ask you to sign in again. If the same sign-in also connected it to MCP, that ends too. - A connected app does not count towards the ten-token cap. ## MCP access is separate AI assistants use the [MCP server](https://developers.plnnrly.com/en/docs/mcp/), not these tokens. They connect by signing you in, and you manage them in the **AI** panel (the sparkles icon in the top bar), where **Disconnect** ends a connection. See [disconnect an app](https://developers.plnnrly.com/en/docs/mcp/#disconnect-an-app). An API token cannot open an MCP session, and an AI assistant's connection cannot call `/api/v2` unless it also asked for `api:` scopes. If you want both an assistant and an integration, connect the assistant by signing in and create an API token for the integration. ## Related - [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/): how a token is sent and what it acts as. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): every ability and permission, and what each covers. - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/): the per-token and per-organisation allowances. --- # Scopes and permissions Every operation needs a token scope and a permission on the user's role. How both are built, what each covers and which one to fix when a call is refused. Every `/api/v2` request passes two separate checks, and every operation page states what each one needs. | Check | What it is | Who controls it | When it fails | | --------------- | ---------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Token scope** | An `api:` ability on the token you sent. | Whoever created the token, or the person who approved the app. | `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope), with `required_ability` listing the abilities that would have worked. | | **Permission** | A permission on the role of the user the token belongs to. | Whoever manages roles. | `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission), with `required_permission` naming the missing one. | The token is only ever a ceiling. Permissions are read again on every request, so taking a permission away from a role narrows existing tokens at once, without touching them. The two failures have different fixes. A scope failure needs a different token; a permission failure needs a role change. The full order of checks, from the organisation's switch to the record's state, is in [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/#the-order-of-checks). ## How a scope is built An ability is `api:{class}` or `api:{class}:{prefix}`. - **class** is `read`, `write` or `destroy`. On the **API tokens** page they are called **Read**, **Write** and **Delete**. - **prefix** is the resource, spelled as in the permission names, such as `projects`, `project_management` or `work_types`. [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#resources) maps each prefix to the name the token form shows. Leave out the prefix and the ability covers every resource of its class: `api:write` satisfies an operation that asks for `api:write:projects`. An app that [signs people in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) asks for these same strings as OAuth scopes, except the destroy class, which OAuth never offers. ## The class ladder Each class includes the ones below it: `write` includes `read`, and `destroy` includes both. | Request | Class required | Satisfied by | | --------------- | -------------- | -------------------------- | | `GET` | `read` | `read`, `write`, `destroy` | | `POST`, `PATCH` | `write` | `write`, `destroy` | | `DELETE` | `destroy` | `destroy` | Every `DELETE` needs the destroy class, whether it deletes softly or for good. `force=true` is accepted on [DELETE /projects/{project}](https://developers.plnnrly.com/en/docs/api/v2/projects/delete/) alone. There it deletes the project for good, and it checks the same `projects::delete` permission as a soft delete. See [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/). Some operations list more than one scope. Any one of them is enough. They are the drive operations, where one endpoint serves items owned by a project, a customer, the organisation or a user, plus the comment and upload operations that work items and drive items share. ## Token scopes A token can carry every class with every resource, plus the three unnarrowed abilities `api:read`, `api:write` and `api:destroy`. 82 of them are named by at least one operation, and the table below lists those, with what each covers. `api:write` and `api:destroy` are not in it because no operation names them, but `api:write` satisfies every read and write, and `api:destroy` every read, write and delete. | Scope | Resources | Operations | | --- | --- | --- | | `api:read` | `/`, `/me` | 2 | | `api:read:absences` | `/absences` | 2 | | `api:read:budget_groups` | `/budget-groups` | 2 | | `api:read:budgets` | `/budgets` | 2 | | `api:read:checkins` | `/checkins` | 2 | | `api:read:company_branches` | `/company-branches` | 2 | | `api:read:customer_categories` | `/customer-categories` | 2 | | `api:read:customer_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 12 | | `api:read:customer_subscriptions` | `/customer-subscriptions`, `/subscription-items` | 4 | | `api:read:customers` | `/contact-roles`, `/contacts`, `/customers` | 6 | | `api:read:events` | `/events` | 2 | | `api:read:free_days` | `/free-days` | 2 | | `api:read:furlough` | `/furlough-balance-mutations` | 2 | | `api:read:holidays` | `/holidays` | 2 | | `api:read:hours` | `/timers` | 2 | | `api:read:invoices` | `/invoices` | 2 | | `api:read:kanban` | `/projects`, `/tasks` | 2 | | `api:read:organization_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 12 | | `api:read:project_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 12 | | `api:read:project_management` | `/comments`, `/labels`, `/sprints`, `/subtasks`, `/todos`, `/uploads`, `/work-item-links`, `/work-items` | 16 | | `api:read:projects` | `/project-links`, `/projects` | 4 | | `api:read:sales_invoices` | `/sales-invoice-rows`, `/sales-invoices` | 4 | | `api:read:subscription_products` | `/subscription-products` | 2 | | `api:read:subscription_types` | `/subscription-type-items`, `/subscription-types` | 4 | | `api:read:times` | `/worklogs` | 2 | | `api:read:user_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 12 | | `api:read:users` | `/users` | 2 | | `api:read:work_types` | `/work-types` | 2 | | `api:read:workflows` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 6 | | `api:write:absences` | `/absences` | 2 | | `api:write:budget_groups` | `/budget-groups` | 2 | | `api:write:budgets` | `/budgets` | 2 | | `api:write:checkins` | `/checkins` | 2 | | `api:write:company_branches` | `/company-branches` | 2 | | `api:write:customer_categories` | `/customer-categories` | 2 | | `api:write:customer_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 13 | | `api:write:customer_subscriptions` | `/customer-subscriptions`, `/subscription-items` | 5 | | `api:write:customers` | `/contact-roles`, `/contacts`, `/customers` | 7 | | `api:write:events` | `/events` | 3 | | `api:write:free_days` | `/free-days` | 2 | | `api:write:furlough` | `/furlough-balance-mutations` | 2 | | `api:write:holidays` | `/holidays` | 2 | | `api:write:hours` | `/timers` | 2 | | `api:write:invoices` | `/invoices` | 1 | | `api:write:kanban` | `/projects`, `/tasks` | 2 | | `api:write:organization_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 13 | | `api:write:project_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 13 | | `api:write:project_management` | `/comments`, `/labels`, `/sprints`, `/subtasks`, `/todos`, `/work-items` | 19 | | `api:write:projects` | `/project-links`, `/projects` | 5 | | `api:write:sales_invoices` | `/sales-invoice-rows`, `/sales-invoices` | 5 | | `api:write:subscription_products` | `/subscription-products` | 3 | | `api:write:subscription_types` | `/subscription-type-items`, `/subscription-types` | 5 | | `api:write:times` | `/worklogs` | 2 | | `api:write:user_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 13 | | `api:write:work_types` | `/work-types` | 2 | | `api:write:workflows` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 6 | | `api:destroy:absences` | `/absences` | 1 | | `api:destroy:budget_groups` | `/budget-groups` | 1 | | `api:destroy:budgets` | `/budgets` | 1 | | `api:destroy:checkins` | `/checkins` | 1 | | `api:destroy:company_branches` | `/company-branches` | 1 | | `api:destroy:customer_categories` | `/customer-categories` | 1 | | `api:destroy:customer_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 6 | | `api:destroy:customer_subscriptions` | `/customer-subscriptions`, `/subscription-items` | 2 | | `api:destroy:customers` | `/contact-roles`, `/contacts`, `/customers` | 3 | | `api:destroy:events` | `/events` | 1 | | `api:destroy:free_days` | `/free-days` | 1 | | `api:destroy:furlough` | `/furlough-balance-mutations` | 1 | | `api:destroy:holidays` | `/holidays` | 1 | | `api:destroy:hours` | `/timers` | 1 | | `api:destroy:kanban` | `/tasks` | 1 | | `api:destroy:organization_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 6 | | `api:destroy:project_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 6 | | `api:destroy:project_management` | `/comments`, `/labels`, `/sprints`, `/subtasks`, `/todos`, `/uploads`, `/work-item-links`, `/work-items` | 8 | | `api:destroy:projects` | `/project-links`, `/projects` | 2 | | `api:destroy:sales_invoices` | `/sales-invoice-rows`, `/sales-invoices` | 2 | | `api:destroy:subscription_products` | `/subscription-products` | 1 | | `api:destroy:subscription_types` | `/subscription-type-items`, `/subscription-types` | 2 | | `api:destroy:times` | `/worklogs` | 1 | | `api:destroy:user_drive` | `/comments`, `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes`, `/uploads` | 6 | | `api:destroy:work_types` | `/work-types` | 1 | | `api:destroy:workflows` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 3 | OAuth apps may ask for the read and write abilities only. ## Permissions 67 permissions gate `/api/v2`. They are the same permissions plnnrly checks in the app, so an integration can do exactly what the user behind the token can do there, and no more. A permission is `{prefix}::{action}`, such as `projects::update`. The prefix is usually the one a narrowed scope names, so `api:write:projects` and `projects::update` guard the same thing from two sides. Time is the exception: reading worklogs needs the scope `api:read:times` and the permission `hours::read`. | Permission | Resources | Operations | | --- | --- | --- | | `absences::manage` | `/absences` | 5 | | `budget_groups::create` | `/budget-groups` | 1 | | `budget_groups::delete` | `/budget-groups` | 1 | | `budget_groups::read` | `/budget-groups` | 2 | | `budget_groups::update` | `/budget-groups` | 1 | | `budgets::create` | `/budgets` | 1 | | `budgets::delete` | `/budgets` | 1 | | `budgets::read` | `/budgets` | 2 | | `budgets::update` | `/budgets` | 1 | | `checkins::read` | `/checkins` | 2 | | `company_branches::create` | `/company-branches` | 1 | | `company_branches::delete` | `/company-branches` | 1 | | `company_branches::read` | `/company-branches` | 2 | | `company_branches::update` | `/company-branches` | 1 | | `customer_categories::create` | `/customer-categories` | 1 | | `customer_categories::delete` | `/customer-categories` | 1 | | `customer_categories::read` | `/customer-categories` | 2 | | `customer_categories::update` | `/customer-categories` | 1 | | `customer_subscriptions::create` | `/customer-subscriptions` | 3 | | `customer_subscriptions::delete` | `/customer-subscriptions`, `/subscription-items` | 2 | | `customer_subscriptions::read` | `/customer-subscriptions`, `/subscription-items` | 4 | | `customer_subscriptions::update` | `/customer-subscriptions`, `/subscription-items` | 2 | | `customers::create` | `/contact-roles`, `/contacts`, `/customers` | 4 | | `customers::delete` | `/contact-roles`, `/contacts`, `/customers` | 3 | | `customers::read` | `/contact-roles`, `/contacts`, `/customers` | 6 | | `customers::update` | `/contact-roles`, `/contacts`, `/customers` | 3 | | `events::manage_self` | `/events` | 6 | | `free_days::manage` | `/free-days` | 5 | | `furlough::create_for_yourself` | `/furlough-balance-mutations` | 1 | | `furlough::manage` | `/furlough-balance-mutations` | 2 | | `holidays::manage` | `/holidays` | 5 | | `hours::read` | `/timers`, `/worklogs` | 4 | | `invoices::create` | `/invoices` | 1 | | `invoices::read` | `/invoices` | 2 | | `kanban::manage_self` | `/projects`, `/tasks` | 5 | | `project_drive::access` | `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 10 | | `project_drive::create` | `/drive-documents`, `/drive-folders`, `/drive-items`, `/notes` | 16 | | `project_management::manage_sprints` | `/sprints` | 5 | | `project_management::manage_work_items` | `/comments`, `/labels`, `/subtasks`, `/todos`, `/work-item-links`, `/work-items` | 21 | | `project_management::view_active_sprint` | `/sprints` | 2 | | `project_management::view_backlog` | `/comments`, `/labels`, `/subtasks`, `/todos`, `/work-item-links`, `/work-items` | 13 | | `projects::create` | `/projects` | 2 | | `projects::delete` | `/projects` | 1 | | `projects::links` | `/project-links` | 5 | | `projects::read` | `/projects` | 2 | | `projects::update` | `/projects` | 1 | | `sales_invoices::create` | `/sales-invoices` | 3 | | `sales_invoices::delete` | `/sales-invoice-rows`, `/sales-invoices` | 2 | | `sales_invoices::read` | `/sales-invoice-rows`, `/sales-invoices` | 4 | | `sales_invoices::update` | `/sales-invoice-rows`, `/sales-invoices` | 2 | | `subscription_products::create` | `/subscription-products` | 2 | | `subscription_products::delete` | `/subscription-products` | 1 | | `subscription_products::read` | `/subscription-products` | 2 | | `subscription_products::update` | `/subscription-products` | 1 | | `subscription_types::create` | `/subscription-types` | 3 | | `subscription_types::delete` | `/subscription-type-items`, `/subscription-types` | 2 | | `subscription_types::read` | `/subscription-type-items`, `/subscription-types` | 4 | | `subscription_types::update` | `/subscription-type-items`, `/subscription-types` | 2 | | `users::read` | `/users` | 2 | | `work_types::create` | `/work-types` | 1 | | `work_types::delete` | `/work-types` | 1 | | `work_types::read` | `/work-types` | 2 | | `work_types::update` | `/work-types` | 1 | | `workflows::create` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 3 | | `workflows::delete` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 3 | | `workflows::read` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 6 | | `workflows::update` | `/workflow-statuses`, `/workflow-transitions`, `/workflows` | 3 | | None (a valid token is enough) | `/`, `/checkins`, `/furlough-balance-mutations`, `/me`, `/timers`, `/uploads`, `/worklogs` | 15 | The drive operations name `project_drive::access` or `project_drive::create`. For items owned by a customer, the organisation or a user, plnnrly checks the matching `customer_drive`, `organization_drive` or `user_drive` permission instead. ### Operations that need no permission A few operations need a valid token with the right scope and no permission at all. Their pages say so. - [Discovery](https://developers.plnnrly.com/en/docs/api/v2/discovery/) and [GET /me](https://developers.plnnrly.com/en/docs/api/v2/me/get/). - Reading and cancelling an upload ticket. - Creating, changing and deleting your own worklogs, timers and check-ins, and reading your own furlough mutations. The record's own rules are the gate here, not a permission. Other people's rows need a permission after all: - worklogs: `times::update_others` or `times::delete_others`, and `times::update_others` to log time for someone else with `on_behalf_of_user_id` - furlough mutations: `furlough::view_all` or `furlough::manage` ## Permissions that change what a call does Some permissions do not decide whether a call runs, but what it may do. **Fields with a permission of their own.** Sending one of these fields without its permission answers `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission) naming the field, except `is_overtime`, which is dropped instead. | Field | On | Permission | Without it | | ---------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------- | | `on_behalf_of_user_id` | worklogs | `times::update_others` | `403` | | `debtor_code` | customers | `billing_settings::update` | `403` | | `should_email` | sales invoices | `sales_invoices::finalize` | `403` | | `is_approved` | furlough mutations | `furlough::manage` | `403` | | `is_overtime` | worklogs | `times::write_overtime` | Dropped, with an `input_ignored` warning in `meta.warnings`. The rest of the write is applied. | A dropped field does not fail the call, so check `meta.warnings` after every write. **Permissions that widen which rows you reach.** Without them you see and change only your own rows: - `times::update_others` and `times::delete_others`: other people's worklogs - `kanban::manage_for_others`: other people's kanban tasks - `events::manage_for_others`: other people's agenda events - `furlough::view_all` and `furlough::manage`: other people's furlough mutations **Permissions a record's rules ask for.** A few writes check a further permission on the record you write to, and answer `403` [forbidden-by-policy](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#forbidden-by-policy) without it: - `projects::notes`: adding a note to a project - `times::write_on_others_tasks`: logging time on a task that belongs to someone else Each operation page names the permissions that apply to it. ## What your organisation exposes Call [GET /](https://developers.plnnrly.com/en/docs/api/v2/discovery/) with any API token. It answers one entry per resource your organisation has, each with its `permission_prefix`, so you can match it with the tables above. Discovery only leaves out resources of features your organisation does not have. It does not check your token or your role, so a listed resource can still answer `403` for you. ## Related - [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/): creating a token with the right scopes. - [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/): the full order of checks. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): every problem type and its fix. --- # How the data fits together A map of the resources behind the API, how they relate, and which areas exist only when your organisation has them. This page maps the resources the API exposes and how they point at each other. Read it before you design a sync or a report, so you know which collection holds what. Each resource name links to its schema page, which lists every field. Paths on this page are relative to `https:///api/v2`. `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## One organisation per host Everything you read or write belongs to one organisation, and the host decides which one. A token works only on the host of the organisation it was created in. Ids are unique per resource inside an organisation, not across resources and not across organisations. Project `12` and work item `12` are different records, and so are project `12` in one organisation and project `12` in another. Key your copy on the resource and the id together, and add the host if your integration serves several organisations. `GET /` lists the resources your organisation has, with their paths. See [Discovery](https://developers.plnnrly.com/en/docs/api/v2/discovery/). ## Projects and work A [project](https://developers.plnnrly.com/en/docs/api/v2/schemas/project/) belongs to a [customer](https://developers.plnnrly.com/en/docs/api/v2/schemas/customer/). It has a project manager, an account manager, tags, and a workflow mode that decides whether its work is planned in sprints or on a simple board. | Resource | What it is | Where you reach it | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | | [Budget](https://developers.plnnrly.com/en/docs/api/v2/schemas/budget/) | A named pot of minutes on a project. Tasks are filed under it and time is logged against it. | `/budgets`, filtered by `project_id` | | [Budget group](https://developers.plnnrly.com/en/docs/api/v2/schemas/budget-group/) | An organisation-wide category that budgets are filed under. | `/budget-groups` | | [Project link](https://developers.plnnrly.com/en/docs/api/v2/schemas/project-link/) | A named external URL pinned to a project, such as a staging site. | `/project-links` | | [Work item](https://developers.plnnrly.com/en/docs/api/v2/schemas/work-item/) | One unit of work on a project's backlog and board. It has an issue key such as `ACM-12`, and it moves through the statuses of the project's workflow. | `/work-items` | | [Subtask](https://developers.plnnrly.com/en/docs/api/v2/schemas/subtask/) | A checklist line on one work item. | `/work-items/{work_item}/subtasks` | | [Work item link](https://developers.plnnrly.com/en/docs/api/v2/schemas/work-item-link/) | A directed link between two work items, such as `blocks` or `duplicates`. | `/work-items/{work_item}/links` | | [Comment](https://developers.plnnrly.com/en/docs/api/v2/schemas/comment/) | A post in the thread of a work item or a Drive item. | `/work-items/{work_item}/comments` | | [File](https://developers.plnnrly.com/en/docs/api/v2/schemas/media/) | A file attached to a work item or a Drive item. | `/work-items/{work_item}/attachments` | | [Sprint](https://developers.plnnrly.com/en/docs/api/v2/schemas/sprint/) | A time-boxed slice of a project's backlog. A work item with no sprint sits in the backlog. | `/sprints` | | [Task](https://developers.plnnrly.com/en/docs/api/v2/schemas/task/) | A kanban card on one employee's board, filed under a budget, with an estimate in minutes. | `/projects/{project}/tasks` | The issue key is built from the customer's code and a running number, so `ACM-12` is a work item on a project of the customer with code `ACM`. Every work item path accepts the issue key in place of the numeric id. > **Note:** > Four different things are called a task. A **work item** with a work type named "task" is a backlog item. A **subtask** is a checklist line on a work item. A **task** under `/projects/{project}/tasks` is a kanban card. A **todo** under `/todos` is an unchecked line in a Drive document that mentions you. ## Time | Resource | What it is | Where you reach it | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------ | | [Worklog](https://developers.plnnrly.com/en/docs/api/v2/schemas/time/) | Minutes one employee worked on one project on one day. It can also point at a task and a work item. | `/worklogs` | | [Timer](https://developers.plnnrly.com/en/docs/api/v2/schemas/timer/) | A running stopwatch on a work item or an event. It is deleted when it is discarded or its minutes are booked. | `/timers` | Both collections return only your own rows unless you name other users. See [Filters that default to you](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/#filters-that-default-to-you). ## Drive Drive items, folders and documents live in a drive, and a drive belongs to an owner: a project, a customer, the organisation, or one user. For those, the owner type decides which permission and which token scope apply. Notes need a drive scope in your token, but the record a note is attached to decides whether you may read or write it. Todos need the `project_management` grant. See [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/). | Resource | What it is | Where you reach it | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | [Drive item](https://developers.plnnrly.com/en/docs/api/v2/schemas/drive-item/) | A document or file in a drive, with its title, folder, tags and sharing. | `/drive-items` | | [Drive folder](https://developers.plnnrly.com/en/docs/api/v2/schemas/drive-folder/) | A folder in a drive. Items and subfolders can take their access from it. | `/drive-folders` | | [Drive document](https://developers.plnnrly.com/en/docs/api/v2/schemas/drive-document/) | The body of a document, in Markdown. The drive item points at it. | `/drive-documents` | | [Note](https://developers.plnnrly.com/en/docs/api/v2/schemas/note/) | A free-text note on a project, a customer or a work item. | `/notes` | | [Todo](https://developers.plnnrly.com/en/docs/api/v2/schemas/user-todo/) | An unchecked task-list line in a document that mentions you. plnnrly rebuilds these from the document, so a todo has no stable id across edits. | `/todos` | Drive items also have comments and files, under `/drive-items/{drive_item}/comments` and `/drive-items/{drive_item}/files`. ## Customers | Resource | What it is | Where you reach it | | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | | [Customer](https://developers.plnnrly.com/en/docs/api/v2/schemas/customer/) | The record that projects, contacts, subscriptions and sales invoices hang off. | `/customers` | | [Contact](https://developers.plnnrly.com/en/docs/api/v2/schemas/contact/) | A customer's contact person, or an employee's emergency contact. Emergency contacts are visible only to that employee. | `/contacts` | | [Contact role](https://developers.plnnrly.com/en/docs/api/v2/schemas/contact-role/) | The list a contact's function is picked from. | `/contact-roles` | | [Customer category](https://developers.plnnrly.com/en/docs/api/v2/schemas/customer-category/) | The categories customers are grouped by. | `/customer-categories` | ## Billing | Resource | What it is | Where you reach it | | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | [Sales invoice](https://developers.plnnrly.com/en/docs/api/v2/schemas/sales-invoice/) | What a customer is billed. Its totals are the sum of its rows. | `/sales-invoices` | | [Sales invoice row](https://developers.plnnrly.com/en/docs/api/v2/schemas/sales-invoice-row/) | One line on a sales invoice. | `/sales-invoices/{sales_invoice}/rows` | | [Customer subscription](https://developers.plnnrly.com/en/docs/api/v2/schemas/customer-subscription/) | A recurring agreement with one customer. A renewal turns it into a sales invoice. | `/customer-subscriptions` | | [Subscription item](https://developers.plnnrly.com/en/docs/api/v2/schemas/subscription-item/) | One line on a customer subscription. | `/customer-subscriptions/{customer_subscription}/items` | | [Subscription type](https://developers.plnnrly.com/en/docs/api/v2/schemas/subscription-type/) | A template that new subscriptions start from. | `/subscription-types` | | [Subscription type item](https://developers.plnnrly.com/en/docs/api/v2/schemas/subscription-type-item/) | One template line, copied onto a subscription when the type is picked. | `/subscription-types/{subscription_type}/items` | | [Subscription product](https://developers.plnnrly.com/en/docs/api/v2/schemas/subscription-product/) | The catalogue of what subscriptions can bill for. | `/subscription-products` | | [Invoice](https://developers.plnnrly.com/en/docs/api/v2/schemas/invoice/) | A legacy invoice header from before sales invoices. It can be read and created, not changed. | `/invoices` | ## Leave | Resource | What it is | Where you reach it | | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | [Absence](https://developers.plnnrly.com/en/docs/api/v2/schemas/absence/) | A period an employee was away from work. | `/absences` | | [Holiday](https://developers.plnnrly.com/en/docs/api/v2/schemas/holiday/) | A recurring named day off. It has no date of its own. | `/holidays` | | [Free day](https://developers.plnnrly.com/en/docs/api/v2/schemas/free-day/) | One date the organisation is closed, made from a holiday or added by hand. | `/free-days` | | [Check-in](https://developers.plnnrly.com/en/docs/api/v2/schemas/checkin/) | One attendance record, from clocking in to clocking out. | `/checkins` | | [Leave balance change](https://developers.plnnrly.com/en/docs/api/v2/schemas/furlough-balance-mutation/) | One entry in an employee's leave balance, in `minutes`. A negative value is a leave request, a positive one is a grant. | `/furlough-balance-mutations` | ## Planning An [event](https://developers.plnnrly.com/en/docs/api/v2/schemas/event/) is one block on a user's agenda, at `/events`. It can point at a kanban task, so time can be logged against it. A recurring series is one event with recurrence fields. Its occurrences are not records of their own, unless one was split off from the series in plnnrly. Like worklogs and timers, `/events` returns only your own rows unless you name other users. ## Organisation | Resource | What it is | Where you reach it | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------- | | [User](https://developers.plnnrly.com/en/docs/api/v2/schemas/user/) | A member of your organisation. Read-only. | `/users`, and `/me` for yourself | | [Company branch](https://developers.plnnrly.com/en/docs/api/v2/schemas/company-branch/) | One of your organisation's own locations. Customers are assigned to it. | `/company-branches` | The API does not create, change or delete users. Roles, permissions and teams are not in the API at all. ## Configuration | Resource | What it is | Where you reach it | | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | [Workflow](https://developers.plnnrly.com/en/docs/api/v2/schemas/workflow/) | A named set of statuses and the moves allowed between them. A project's `workflow_id` names the workflow its work items move through, and can be `null`. | `/workflows` | | [Workflow status](https://developers.plnnrly.com/en/docs/api/v2/schemas/workflow-status/) | One status in a workflow, the column a work item sits in. | `/workflow-statuses` | | [Workflow transition](https://developers.plnnrly.com/en/docs/api/v2/schemas/workflow-transition/) | One allowed move from one status to another. | `/workflow-transitions` | | [Work type](https://developers.plnnrly.com/en/docs/api/v2/schemas/work-type/) | The type a work item is filed under, such as bug or story. | `/work-types` | | [Label](https://developers.plnnrly.com/en/docs/api/v2/schemas/label/) | A coloured tag for work items. Each label belongs to one project. | `/labels` | Changing a work item's status is an action, not a field write. See [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/). ## How records point at each other A record points at another through a foreign key, usually named after the relation, with the related record next to it. On a work item row from `GET /work-items`: ```jsonc { "sprint_id": 31, "sprint": { "id": 31 } // 33 more fields: id, issue_key, title, priority, due_date, estimation, remaining_estimate, order, column_order, version, created_at, updated_at, project_id, task_id, workflow_status_id, work_type_id, parent_id, assignee_id, reporter_id, web_url, workflow_status, parent, assignee, reporter, work_type, comments_count, subtasks_count, attachments_count, child_work_items_count, labels_count, drive_items_count, links_count, links } ``` The related record stays a bare `{"id": …}` until you name it in `include`. A few relations are always filled in, such as the customer on a project. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/). Some fields can point at one of several kinds of record. They come as a `{name}_type` and `{name}_id` pair, plus the record itself. A Drive item's owner, for example: ```jsonc { "owner_type": "project", "owner_id": 12, "owner": { "id": 12 } // 23 more fields: id, title, group, kind, source, folder_id, drive_folder, content_record_type, content_record_id, content_record, order_column, created_by, archived_by, inherits_permissions, general_access, access, created_at, updated_at, deleted_at, comments_count, files_count, tags_count, links } ``` The type is always a short name, such as `project`, `customer`, `organization`, `user`, `work_item` or `event`, never an internal class name. The schema page of each resource lists the names its fields can hold. To filter on such a pair, send both halves; see [Paired filters](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/#paired-filters). ## Areas your organisation may not have Two areas exist only when your organisation has the feature behind them: | Area | Resources | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Billing | Sales invoices and their rows, customer subscriptions and their items, subscription types and their items, subscription products, and legacy invoices | | Configuration (the sprints feature) | Workflows, workflow statuses, workflow transitions, work types and labels | Sprints and work items are always there, with or without the sprints feature. Without the feature, every operation in that area answers `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found), and `GET /` does not list it. Call `GET /` first if your integration has to work for more than one organisation. The **MCP & API** switches that turn the whole API on or off are a separate thing. See [For administrators](https://developers.plnnrly.com/en/docs/administration/). ## Related - [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/): how dates, money, durations and empty values are written. - [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/): how much of each record you get back. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): which grant each area needs. --- # Data formats How the API writes dates, times, durations, money, ids, enums and empty values, and the forms it accepts back. This page lists the formats the API uses on the wire. Send values back in the same forms: the API checks them strictly and does not guess. A worklog row from `GET /worklogs` shows most of them at once: ```jsonc { "id": 9021, "date": "2026-06-15", "minutes": 90, "description": "Sprint review with Acme", "work_item_id": 812, "is_overtime": false, "created_at": "2026-06-15T07:30:00Z", "updated_at": "2026-06-15T07:30:00Z" // 15 more fields: project_id, project, budget_id, budget, task_id, task, work_item, user_id, user, is_not_billable, exceeded_budget, reviewed_by, invoice_id, sales_invoice_row_id, links } ``` ## Field names Every key is `snake_case`. - A relation usually comes as a foreign key named `{relation}_id`, next to the relation itself: `sprint_id` and `sprint`. Three do not: a Drive item's `drive_folder` uses `folder_id`, a Drive folder's `parent_folder` uses `parent_id`, and a contact's `role` uses `contact_role_id`. - A counter is an integer named `{relation}_count`, such as `comments_count`. - Booleans are JSON `true` and `false`. Most are named `is_…`, but not all: `auto_renew` and `active` are booleans too. Go by the type in the schema, not by the name. ## Calendar dates A calendar date is `YYYY-MM-DD`, such as `"2026-06-15"`. It has no time and no time zone. A worklog's `date` and a work item's `due_date` are calendar dates. A time of day on its own, as in a user's `working_hours`, is `HH:MM`, such as `"08:30"`, also without a zone. ## Date-times A date-time is UTC, to the second, with a `Z`: `"2026-06-15T07:30:00Z"`. Responses always use this form. Send date-times in exactly this form too, in request bodies and in filters. An offset such as `+02:00` or fractions of a second are refused: | Where | What you get | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | Request body | `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed), code `date_format` | | Filter | `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter), code `invalid_format` | Convert to UTC before you send. A meeting at 09:30 in Amsterdam on 15 June 2026 is `2026-06-15T07:30:00Z`, because Amsterdam is two hours ahead of UTC in summer. To show a date-time in a person's own zone, read the `timezone` field on their [user](https://developers.plnnrly.com/en/docs/api/v2/schemas/user/) record, such as `Europe/Amsterdam`. When it is `null`, the organisation's default zone applies. ## Durations A duration is a whole number of minutes, as an integer. One and a half hours is `90`. The field name does not always say so. `minutes` and `logged_minutes` do, but a work item's `estimation` and a budget's `budget` are minutes as well. The field description in the schema states the unit. One exception: on a sales invoice row that bills budget time, `quantity` can hold hours, as a decimal string like any amount. The row's `billable_minutes` holds that time in minutes. ## Money, percentages and coordinates | Kind | Form | Example | | ----------------------------- | ------------------------------------------ | ------------------------ | | Money | A decimal string with two decimals | `"1250.00"` | | Percentage | A whole number from 0 to 100 | `21` means 21%, not 0.21 | | Coordinates in a response | A decimal string with up to seven decimals | `"52.3675734"` | | Coordinates in a request body | A JSON number | `52.3675734` | Money is a string so that no rounding happens on the way. Send amounts as strings with at most two decimals. There is no currency field. A sales invoice row, for example: ```jsonc { "quantity": "2.50", "unit_price": "95.00", "vat_rate": 21, "vat_amount": "49.88", "total_amount": "237.50", "billable_minutes": 150 // 18 more fields: id, sales_invoice_id, sales_invoice, subscription_item_id, budget_id, exact_item_id, exact_vat_code, exact_cost_center, exact_gl_account, name, description, hourly_rate, is_aggregated_price, sort_order, created_at, updated_at, deleted_at, links } ``` ## Identifiers Every `id` is an integer, unique among records of the same resource in your organisation: project `12` and work item `12` are different records. A work item also has an `issue_key`, a string such as `"ACM-12"`. Every work item path accepts either form, so `/work-items/ACM-12` and `/work-items/812` are the same record. Request bodies and filters take the numeric id, such as `work_item_id` on a worklog. The one exception is `after_work_item_id` on the [move action](https://developers.plnnrly.com/en/docs/api/v2/work-items/move/), which takes either. Store the numeric id as your key: an `issue_key` can be `null`. ## Absent, null and empty These three mean different things: | What you see | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The field is absent | You did not ask for it. Included relations, on-demand fields and detail-only fields appear only when you request them. | | `null` | There is no value, such as `end_date` on a project with no planned end. A few on-demand fields are also `null` when your access does not cover them; their description says so. | | `[]` | The list exists and is empty. | See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/) for how to request what is absent. ## Enum values Enum values are sent and returned exactly as the schema lists them. They are not always `snake_case`: a project's status can be `on-hold`, and `on_hold` is refused. Most enums can gain values in a later release. The OpenAPI document marks each of them with `x-extensible-enum: true`, in 200 places. Treat a value you do not recognise as "unknown, show it as is", never as an error. A value that is not in the list is `422` with code `in` in a request body, and `400` with code `invalid_format` in a filter. ## Rich text | Field | Format | | --------------------------------------------------------------------------- | ------------------------------------------------------------- | | Work item `description`, comment `body`, note `content`, and similar fields | HTML from the plnnrly editor, sanitised before it is returned | | Drive document `content` | GitHub-Flavored Markdown | | Drive document `content_html` | The same document as sanitised HTML, on request | Markdown cannot hold text colours, highlights, alignment or @mentions, so writing a document back as Markdown drops them. Read `content_html` first when a document may contain them. The [drive document](https://developers.plnnrly.com/en/docs/api/v2/drive-documents/get/) operation explains the details. ## Links back to plnnrly Projects and work items carry a `web_url`: an absolute URL that opens the record in plnnrly, on your organisation's own address. It is stable, so you can store it and show it as a link. It is read-only. ## Language Data comes back exactly as people entered it. Nothing is translated. Problem documents and warnings are always in English, whatever language a user has chosen in plnnrly. Their `type`, `code` and enum values are identifiers: switch on those, not on the English text. ## Related - [How the data fits together](https://developers.plnnrly.com/en/docs/api/v2/guides/data-model/): which resource holds what. - [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/): the same formats in filter values. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): what a refused value looks like. --- # Pagination Cursor paging by default, page numbers on collections that count their rows, page sizes, totals, and how to walk a whole collection. Every collection read returns one page of rows at a time. This page shows how to read the page state, move between pages, and walk a whole collection. Paths are relative to `https:///api/v2`, where `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## The envelope A collection answers with `data`, `meta.page` and `links`. The first page of `GET /projects`: ```jsonc { "data": [ // 25 projects in the summary view ], "meta": { "page": { "per_page": 25, "next_cursor": "", "prev_cursor": null, "total": 184, "current_page": null, "last_page": null } }, "links": { "self": "https:///api/v2/projects?sort=title&trashed=without&per_page=25&view=summary", "next": "https:///api/v2/projects?sort=title&trashed=without&per_page=25&cursor=&view=summary", "prev": null } } ``` - `data` holds the rows. - `meta.page` holds the page state. `current_page` and `last_page` are present on collections that support page numbers, and `null` until you send `page`. - `links` holds ready-made URLs. `next` and `prev` repeat every parameter that shaped the page, including the defaults, so following them keeps your query intact. `meta.warnings` appears next to `meta.page` when there is something to report. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/#warnings). Every member of the envelope is described on the [pagination schema](https://developers.plnnrly.com/en/docs/api/v2/schemas/pagination/) page. ## Page size `per_page` sets the number of rows. It defaults to 25 and goes up to 100. When `include` names a relation that holds several records, such as `labels` on a work item, the maximum is 25. Above that you get `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter) with code `out_of_range`. See [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/). All limits are listed together in [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/). ## Cursor paging Cursor paging is the default. Send no paging parameter and you get the first page. To get the next page, request `links.next` as it is. It is already URL-encoded. You can also send the cursor yourself, as `cursor`; it is the same value as `meta.page.next_cursor`: ```bash curl -G "https:///api/v2/projects" \ -H "Authorization: Bearer " \ --data-urlencode "cursor=" ``` On the last page, `links.next` and `next_cursor` are `null`. A cursor is tied to the query that produced it: its `sort`, its filters, `q`, `trashed` and `view`. Change any of those and the cursor no longer fits, which is `400` [invalid-cursor](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-cursor). Start again from the first page when you change the query. You may change `per_page` between pages. Never build or edit a cursor yourself. Use a cursor to fetch the next page, not to resume a job days later. To pick up where a sync left off, filter on `updated_at` instead; see [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/). With page numbers, a row added to an earlier page shifts every later row by one, so you read one row twice. A cursor is not thrown off by rows added or deleted elsewhere: it continues after the last row you read. A row whose sort value changes while you page can still move past the cursor, so sort by `id` when you must not miss a row. ## Page numbers Some collections also accept `page`, counted from 1 up to 1000. They are the collections that count their rows on every request, because a page number is only useful when you know the last page: - **Projects:** [`/budget-groups`](https://developers.plnnrly.com/en/docs/api/v2/budget-groups/list/), [`/budgets`](https://developers.plnnrly.com/en/docs/api/v2/budgets/list/), [`/project-links`](https://developers.plnnrly.com/en/docs/api/v2/project-links/list/), [`/projects`](https://developers.plnnrly.com/en/docs/api/v2/projects/list/) - **Work items:** [`/work-items/{work_item}/attachments`](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/list/), [`/work-items/{work_item}/links`](https://developers.plnnrly.com/en/docs/api/v2/work-items/links/list/), [`/work-items/{work_item}/subtasks`](https://developers.plnnrly.com/en/docs/api/v2/work-items/subtasks/list/) - **Sprints:** [`/sprints`](https://developers.plnnrly.com/en/docs/api/v2/sprints/list/) - **Time:** [`/timers`](https://developers.plnnrly.com/en/docs/api/v2/timers/list/) - **Drive:** [`/drive-items/{drive_item}/files`](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/list/) - **Customers:** [`/contact-roles`](https://developers.plnnrly.com/en/docs/api/v2/contact-roles/list/), [`/contacts`](https://developers.plnnrly.com/en/docs/api/v2/contacts/list/), [`/customer-categories`](https://developers.plnnrly.com/en/docs/api/v2/customer-categories/list/), [`/customers`](https://developers.plnnrly.com/en/docs/api/v2/customers/list/) - **Billing:** [`/customer-subscriptions`](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/list/), [`/customer-subscriptions/{customer_subscription}/items`](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/items/list/), [`/invoices`](https://developers.plnnrly.com/en/docs/api/v2/invoices/list/), [`/sales-invoices`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/list/), [`/sales-invoices/{sales_invoice}/rows`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/rows/list/), [`/subscription-products`](https://developers.plnnrly.com/en/docs/api/v2/subscription-products/list/), [`/subscription-types`](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/list/), [`/subscription-types/{subscription_type}/items`](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/items/list/) - **Leave:** [`/absences`](https://developers.plnnrly.com/en/docs/api/v2/absences/list/), [`/free-days`](https://developers.plnnrly.com/en/docs/api/v2/free-days/list/), [`/holidays`](https://developers.plnnrly.com/en/docs/api/v2/holidays/list/) - **Organization:** [`/company-branches`](https://developers.plnnrly.com/en/docs/api/v2/company-branches/list/), [`/users`](https://developers.plnnrly.com/en/docs/api/v2/users/list/) - **Configuration:** [`/labels`](https://developers.plnnrly.com/en/docs/api/v2/labels/list/), [`/work-types`](https://developers.plnnrly.com/en/docs/api/v2/work-types/list/), [`/workflow-statuses`](https://developers.plnnrly.com/en/docs/api/v2/workflow-statuses/list/), [`/workflow-transitions`](https://developers.plnnrly.com/en/docs/api/v2/workflow-transitions/list/), [`/workflows`](https://developers.plnnrly.com/en/docs/api/v2/workflows/list/) On any other collection, `page` is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). The operation page lists `page` when a collection accepts it. Send `page` and the response switches to page mode. `GET /projects?page=2`: ```jsonc { "data": [ // 25 projects in the summary view ], "meta": { "page": { "per_page": 25, "next_cursor": null, "prev_cursor": null, "total": 184, "current_page": 2, "last_page": 8 } }, "links": { "self": "https:///api/v2/projects?sort=title&trashed=without&per_page=25&page=2&view=summary", "next": "https:///api/v2/projects?sort=title&trashed=without&per_page=25&page=3&view=summary", "prev": "https:///api/v2/projects?sort=title&trashed=without&per_page=25&page=1&view=summary" } } ``` In page mode, `links.next` and `links.prev` are page-number links, and both cursors are `null`. `page` and `cursor` cannot be combined: sending both is `400` with code `mutually_exclusive`. ### Paging past the end `links.next` is `null` on the last page. If you ask for a page beyond `last_page`, you get an empty `data` list, not an error, and `current_page` repeats the number you sent. A page above 1000 is `400` with code `out_of_range`; use cursors to go further. ### Lists people reorder Some numbered collections are sorted by default on a position that changes whenever someone drags a row in plnnrly, such as labels, sprints, subtasks and work types. If someone reorders the list between two of your requests, page numbers can skip a row or show one twice. The `page` parameter on those operation pages says so. For a walk that cannot skip rows, send `sort=id` and follow `links.next` without `page`. ## Totals `meta.page.total` is the number of rows that match your query. - On collections that accept `page`, `total` is always a number. They do not accept `with_total`: sending it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). - On every other collection, `total` is `null` unless you send `with_total=1`. Counting is the expensive part of those queries, so `with_total=1` costs 2 more rate units: 3 in place of 1, or 5 together with `view=detail`. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). The operation page lists `page` or `with_total`, so you can see which kind a collection is before you call it. ```bash curl -G "https:///api/v2/work-items" \ -H "Authorization: Bearer " \ --data-urlencode "with_total=1" ``` ## Walking a whole collection Follow `links.next` until it is `null`. This exports every work item you can read, 100 at a time. It uses `jq` to read the JSON: ```bash set -euo pipefail url="https:///api/v2/work-items?per_page=100&sort=id" while [ -n "$url" ]; do body=$(curl -sS --fail-with-body "$url" -H "Authorization: Bearer ") echo "$body" | jq -c '.data[]' url=$(echo "$body" | jq -r '.links.next // empty') done ``` The script stops on an error response. On a `429`, wait as `Retry-After` says and request the same URL again; see [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). Sort by `id` for an export. It never changes, so a row cannot move between pages while you read. Sorting by `updated_at` moves a row every time someone saves it, which is what you want for a sync and not for an export. ## Related - [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/): narrow and order the rows before you page through them. - [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/): paging by `updated_at` with a high-water mark. - [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/#etag): re-read an unchanged page as a `304`. --- # Filtering and sorting Narrow a collection with bracket filters and operators, search it with q, order it with sort, and read soft-deleted rows. Filters, search and sorting are query parameters on collection reads. Each operation page lists exactly which filters, operators, sort keys and search it accepts, so check the operation before you send a request. The API never ignores a parameter it does not know. An unknown filter, operator or sort key is `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter), and `errors[].detail` lists what the operation does accept. Filters, `q` and `sort` on a single-record read are refused the same way. Paths are relative to `https:///api/v2`, where `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## Bracket syntax A filter is `filter[field]`, with an optional operator in a second pair of brackets: ```text filter[status]=active filter[start_date][gte]=2026-01-01 filter[status][in]=active,on-hold filter[end_date][null]=true ``` Write the brackets as brackets. Percent-encoding them is also fine. Other shapes, such as `filter.status` or `filter=status:active`, are refused. Every filter you send must match, so filters combine with AND: ```bash curl -G "https:///api/v2/work-items" \ -H "Authorization: Bearer " \ --data-urlencode "filter[sprint_id][null]=true" \ --data-urlencode "filter[updated_at][gte]=2026-09-01T00:00:00Z" ``` This returns the work items in the backlog that changed since 1 September 2026. ## Operators | Operator | Sent as | Matches | | -------- | ----------------------------- | ------------------------------------------------------------------------------- | | *(none)* | `filter[field]=value` | Equal to the value. | | `ne` | `filter[field][ne]=value` | Not equal. A row whose value is null does not match. | | `gte` | `filter[field][gte]=value` | Greater than or equal to. | | `lte` | `filter[field][lte]=value` | Less than or equal to. | | `lt` | `filter[field][lt]=value` | Less than. | | `in` | `filter[field][in]=a,b,c` | Any value in a comma-separated list of at most 100. | | `null` | `filter[field][null]=true` | `true` keeps rows where the field is empty, `false` keeps rows where it is set. | | `starts` | `filter[field][starts]=value` | Values that begin with the text. | Each filter accepts only some operators. Typical sets: | Kind of field | Usual operators | | ---------------------------------------------- | ----------------------------------- | | Dates, such as `due_date` or `start_date` | `=`, `gte`, `lte`, and often `null` | | `created_at`, `updated_at` and `last_activity` | `=`, `gte`, `lt` | | Ids, such as `project_id` | `=`, `in`, and often `null` | | Statuses | `=`, `ne`, `in` | Every collection has `created_at` and `updated_at` filters. An operator the filter does not accept is `400` with code `unknown_operator`, and more than 100 values in `in` is code `count_exceeded`. In an `in` list, write a comma that is part of a value as `\,` and a backslash as `\\`. `starts` takes its value as it is: commas, `%`, `_` and backslashes are matched literally. All limits are listed together in [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/). ## Value formats Filter values use the same formats as the rest of the API. See [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/). - Dates: `YYYY-MM-DD`. - Date-times: `YYYY-MM-DDTHH:MM:SSZ` only, in UTC. An offset or fractions of a second are refused. - Booleans: `true`, `false`, `1` or `0`. - Decimals: no more decimals than the field allows, usually two. - Enum values: exactly as the schema lists them, such as `on-hold`. - Record types in a pair of filters: the short name, such as `project`. A value in the wrong format is `400` with code `invalid_format`, and the `detail` shows the expected form. ## Filters that name a user Most filters on a user, such as `filter[user_id]` or `filter[assignee_id]`, accept `me` in place of an id. The filter's description on the operation page says so where it does. `me` also works inside a list: ```text filter[assignee_id]=me filter[user_id][in]=me,42 ``` Some of these filters need a permission before you may name someone else. The filter's description on the operation page names it. Without it, naming another user is `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission), and the response names the permission in `required_permission`. Filters on who is assigned to or manages something, such as `assignee_id` or `project_manager_id`, need no extra permission. If a generated client refuses `me` because it reads the filter as an integer, send your own id instead. `GET /me` returns it. ## Filters that default to you These collections return only your own rows unless you send `filter[user_id]`: | Collection | Permission needed to name someone else | | ------------------------------- | ------------------------------------------------- | | `GET /worklogs` | `times::update_others` | | `GET /timers` | `times::update_others` | | `GET /events` | `events::manage_for_others` | | `GET /projects/{project}/tasks` | `kanban::manage_for_others` | | `GET /todos` | None exists: naming another user is always `403`. | ## Relationship filters A few filters look one step into a related record: | Collection | Filter | Matches | | ----------------- | ------------------------ | ------------------------------------------------------ | | `GET /projects` | `filter[customer.code]` | Projects of the customer with this code, such as `ACM` | | `GET /projects` | `filter[tags.title]` | Projects carrying a tag with exactly this title | | `GET /work-items` | `filter[labels.id]` | Work items carrying this label | | `GET /work-items` | `filter[drive_items.id]` | Work items linked to the Drive item with this id | They go one step only. A deeper path, such as `filter[customer.category.name]`, is `400` with code `depth_exceeded`. Some of them read another area of plnnrly and need that area's grant as well. `filter[customer.code]` needs read access to customers, both as a permission and in your token. Without it the filter is `400` with code `unknown_filter`, and the `detail` says what is missing. The operation page shows the extra requirement next to the filter. ## Paired filters A field that points at one of several kinds of record is filtered with a pair: the type and the id. Send both, or the request is `400` with code `paired_filter_missing`. ```bash curl -G "https:///api/v2/drive-items" \ -H "Authorization: Bearer " \ --data-urlencode "filter[owner_type]=project" \ --data-urlencode "filter[owner_id]=12" ``` The type is a short name, such as `project` or `work_item`. Pairs exist on, for example, contacts (`contactable_…`), Drive items and folders (`owner_…`), notes (`notable_…`), timers (`timeable_…`) and subscription lines (`subscribable_…`). ## Search `q` searches the collection's main text fields, such as a title, a name, an email address or an issue key: ```bash curl -G "https:///api/v2/users" \ -H "Authorization: Bearer " \ --data-urlencode "q=acme.com" ``` - The text is matched as one piece anywhere in the field. It is not split into words. - `q` combines with filters: a row must match both. - It takes at most 255 characters. Longer is `400` with code `out_of_range`. - A collection with no searchable text does not accept `q`: sending it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). The operation page lists `q` when it is accepted. A search costs 2 more rate units than the same read without it. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). ## Sorting `sort` takes up to three keys, separated by commas. Put `-` in front of a key to sort descending: ```text sort=-updated_at,id ``` - Every collection has a default sort, shown on its operation page. `GET /projects` sorts by `title`, for example, not by recent activity. - `id` is always added as the last key, so rows with equal values come back in a stable order. - Every collection accepts `id` and `updated_at`. Use `id` for a full export, because it never changes. Use `updated_at,id` for a sync. - You cannot sort on a related record's field, such as `customer.name`. An unknown key is `400` with code `unknown_sort_key`. A key sent twice is code `invalid_format`, and a fourth key is code `count_exceeded`. A cursor belongs to the order it was made for, so a cursor from the old `sort` no longer works after you change it. See [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/#cursor-paging). ## Soft-deleted rows Some resources keep a record after it is deleted, and most of those can restore it. On those, `trashed` decides whether deleted records are returned: | Value | Returns | | --------- | ------------------------------------------ | | `without` | Records that are not deleted. The default. | | `with` | Both. | | `only` | Deleted records only. | `trashed` also works on a single-record read. `GET /projects/12?trashed=with` returns project 12 even when it is deleted; without it, a deleted project is `404`. With `trashed=only`, a project that is not deleted is `404`. A collection nested under a record that keeps deleted records also accepts `trashed`, even when its own rows are deleted permanently. On `GET /projects/{project}/tasks`, `GET /drive-items/{drive_item}/comments` and `GET /drive-items/{drive_item}/files`, `trashed=with` lists the rows of a deleted project or Drive item; without it, a deleted parent is `404`. Any other collection of a resource that deletes permanently refuses `trashed` with `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). The operation page lists `trashed` where it applies, and [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/) lists which resources keep deleted records. ## Related - [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/): page through what your filters return. - [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/): the value formats in detail. - [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/): choose which fields and related records come back. --- # Views and includes Choose a field set with view, inline related records and on-demand fields with include, and read meta.actions and warnings. Two query parameters decide how much of each record you get back. Neither of them lists individual fields. | Parameter | What it controls | Default | | --------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `view` | Which of the resource's two field sets you get: `summary` or `detail`. | `summary` on a collection; `detail` on a single read and on the record a write returns. | | `include` | Which related records and on-demand fields are filled in. | Nothing extra. | Paths are relative to `https:///api/v2`, where `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## There is no `fields` parameter You cannot pick single fields. `?fields=…` is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter), and `errors[0].detail` points you to `view`. Use `view` for the field set and `include` for the extras below. ## Summary and detail Each resource has a summary and a detail field set. On many resources they are the same. Where they differ, the detail set adds the larger fields, such as: - a work item's `description` and its location fields (`location_name`, `location_address`, `location_latitude`, `location_longitude`) - a worklog's `reason` and `billable_change_reason` - a Drive document's `content` - a user's `working_hours` and `work_locations` The reference marks which fields are detail-only. In the summary view those fields are absent, not `null`. ```bash curl -G "https:///api/v2/work-items" \ -H "Authorization: Bearer " \ --data-urlencode "view=detail" ``` `view=detail` on a collection costs 3 rate units in place of 1, or 5 together with `with_total=1`. On a single read and on a write it costs nothing extra. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). ## Include `include` takes up to five names, separated by commas. Each name is a related record or an on-demand field of the resource you are reading: ```bash curl -G "https:///api/v2/work-items" \ -H "Authorization: Bearer " \ --data-urlencode "include=sprint,labels" ``` One row of the answer: ```jsonc { "id": 812, "issue_key": "ACM-12", "title": "Export timesheets as CSV", "sprint_id": 31, "sprint": { "id": 31, "name": "Sprint 14", "is_active": true }, "labels": { "data": [ { "id": 5, "name": "Backend", "color": "#ef4444" } ], "meta": { "count": 1, "truncated": false } } // 30 more fields: priority, due_date, estimation, remaining_estimate, order, column_order, version, created_at, updated_at, project_id, task_id, workflow_status_id, work_type_id, parent_id, assignee_id, reporter_id, web_url, workflow_status, parent, assignee, reporter, work_type, comments_count, subtasks_count, attachments_count, child_work_items_count, labels_count, drive_items_count, links_count, links } ``` The rules: - Each name is a single relation or field. A dotted path such as `sprint.project` is `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter) with code `depth_exceeded`. Read the next record with its own request. - A sixth name is code `count_exceeded`. - An unknown name is code `unknown_include_path`, and `errors[].detail` lists the names this resource accepts. - `include` is documented as a parameter on collection reads. Each name costs extra rate units: 1 for a single related record or a field, 3 for a relation that holds several records. `include=sprint,labels` costs 1 + 1 + 3 = 5 units. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). The operation page lists every name it accepts, what it returns, and any extra grant it needs. All limits are listed together in [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/). ### One record or several A relation to **one** record, such as `sprint`, is a bare `{"id": 31}` until you include it. Included, it becomes a short summary of that record. It is `null` when nothing is linked. A relation to **several** records, such as `labels`, is absent until you include it. Included, it is always wrapped as `{ "data": [...], "meta": {...} }`: - `meta.truncated` is always present. It is `true` when there were more rows than the cap. - `meta.count` is the full number of related records, on relations that count them. With such a relation in `include`, `per_page` goes up to 25 instead of 100. A larger value is `400` with code `out_of_range`. ### On-demand fields Some fields are left out unless you name them in `include`, because they cost extra work to produce: | Resource | Field | What it gives you | | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Files on a work item or Drive item | `url` | A download URL, which needs your token. This is the only way to get one. See [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). | | Files on a work item or Drive item | `url_expires_at` | When `url` stops working. Always `null` today: the URL does not expire. | | Drive documents | `content_html` | The document as sanitised HTML, with the colours, highlights and @mentions that Markdown cannot hold. | | Projects | `logged_minutes` | The minutes logged on the project that you may see. | ### Includes that need their own grant Some includes read another area of plnnrly, and need that area's permission and token scope on top of the operation's own. `include=assignee` on work items reads users, for example. Without that grant, a related record in `include` is `400` with code `unknown_include_path`, and the `detail` says it is not available to you. It is not a `403`. An on-demand field behaves differently: `include=logged_minutes` without access to time entries returns `logged_minutes: null`. ### Names you cannot include - **A relation that is always filled in.** A work item's `workflow_status` and a worklog's `project` come with every row. Naming one is `400` with code `unknown_include_path`, telling you to remove it. - **A relation with no summary form.** A Drive item's `content_record` is always a bare reference. Naming it is `400` with code `unknown_include_path`; read the record by its `content_record_id`. - **Anything, on a resource with nothing to include.** `include` there is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). A relation that is always filled in can still come back as a bare `{"id": …}`, when it points at a record you may not read. ### Truncation A relation that holds several records returns at most 25 of them, or up to 50 for `tags`. When there are more, three things happen: 1. The relation's `meta.truncated` is `true`. 2. `meta.warnings` on the response gets an entry with code `include_truncated`. The status code stays `200`, and the rest of `data` is unchanged. 3. The row has a link of the same name in `links`, such as `links.comments` or `links.labels` on a work item. Follow it to page through the full list. Without that link, the warning's `detail` says that only the first 25 are listed. ```jsonc { "data": [ { "id": 812, "comments": { "data": [ // 25 comments, each with id, author_type, author_id, author and created_at ], "meta": { "truncated": true } }, "links": { "self": "https:///api/v2/work-items/812", "comments": "https:///api/v2/work-items/812/comments" // 6 more links: subtasks, links, attachments, child_work_items, labels, drive_items } // 33 more fields: issue_key, title, priority, due_date, estimation, remaining_estimate, order, column_order, version, created_at, updated_at, project_id, task_id, workflow_status_id, work_type_id, sprint_id, parent_id, assignee_id, reporter_id, web_url, workflow_status, parent, assignee, reporter, work_type, sprint, comments_count, subtasks_count, attachments_count, child_work_items_count, labels_count, drive_items_count, links_count } ], "meta": { "page": { // 4 page fields: per_page, next_cursor, prev_cursor, total }, "warnings": [ { "code": "include_truncated", "detail": "The `comments` include returned more than 25 rows; follow links.comments for the rest.", "parameter": "include" } ] }, "links": { // 3 links: self, next, prev } } ``` Never treat a truncated relation as the whole set. Check `meta.truncated`, or the `include_truncated` warning. Every relation that can hold more records than its include returns has such a link, with one exception: a work item has `links.labels` only while your organisation has the Sprints feature, because `GET /labels` needs it too. `tags` needs no link: a project, a customer and a Drive item take at most 50 tags each, and `include=tags` returns up to 50, so it always holds the whole set. ### Changing a set you read through include Some sets are written as a list of ids, such as `label_ids` on a work item. The list replaces the whole set: an id you leave out is removed. Before you add or remove one entry, read the full set from the row's link, such as `links.labels`, not from a truncated include. Then send the full list with your change. Each list takes a limited number of ids, stated on the field in the reference. plnnrly links any number of labels and documents to a work item, so those sets can grow past that number there. Such a set cannot be replaced through the API: change it in plnnrly. ## Actions you can call now On a single-record read, `meta=actions` adds the actions you may call on that record right now: ```bash curl "https:///api/v2/work-items/ACM-12?meta=actions" \ -H "Authorization: Bearer " ``` ```jsonc { "data": { // 40 fields in the detail view: id, issue_key, title, description, priority, due_date, estimation, remaining_estimate, location_name, location_address, location_latitude, location_longitude, order, column_order, version, created_at, updated_at, project_id, task_id, workflow_status_id, work_type_id, sprint_id, parent_id, assignee_id, reporter_id, web_url, workflow_status, parent, assignee, reporter, work_type, sprint, comments_count, subtasks_count, attachments_count, child_work_items_count, labels_count, drive_items_count, links_count, links }, "meta": { "actions": ["move", "transition"] } } ``` The list takes your token, your permissions and the record's current state into account. When no action applies, for example with a read-only token, `meta.actions` is left out. `meta=actions` works on single-record reads of resources that have actions, such as work items and sprints. On a collection or a write it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). See [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/). ## Warnings `meta.warnings` is a list of notes about your request. A warning never changes the status code and never changes `data`. It appears only when there is something to say. | Field | Meaning | | ------------- | ---------------------------------------------------------------------------------------------- | | `code` | What happened. Switch on this, never on `detail`. | | `detail` | An explanation in English. | | `pointer` | The field in your request body the warning is about, as a JSON pointer such as `/is_overtime`. | | `parameter` | The query parameter the warning is about, such as `include`. | | `sunset` | For a deprecation: the date it stops working. | | `replacement` | For a deprecation: what to use instead. | The codes: | Code | Meaning | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `include_truncated` | An included relation hit its cap. See [Truncation](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/#truncation). | | `input_ignored` | A field in your write was dropped because your permissions do not cover it. The rest of the write was applied. `pointer` names the field. | | `already_in_target_state` | A restore found the record already restored, so nothing changed. | | `signed_url_unavailable` | A file's download `url` could not be made, so it is `null`. See [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). | | `deprecated_field`, `deprecated_parameter`, `deprecated_endpoint` | You used something that is deprecated. See [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/). | New codes can be added. Treat a code you do not know as a note to log, never as an error. Each operation page lists the codes it can return. After every write, check `meta.warnings` for `input_ignored`. Without that check, a dropped field goes unnoticed. ## Related - [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/): page through the full list a truncated relation links to. - [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/): what absent, `null` and `[]` mean. - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/): what `view` and `include` cost. --- # Concurrency and ETags Use ETags to skip re-reading data that has not changed, and to stop a write from overwriting a change you have not seen. Every record or page you read or write comes with an `ETag` header. Send it back on your next request. With `If-None-Match`, a read returns no body when nothing changed. With `If-Match`, a write is refused when someone else changed the record first. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## ETag There are two kinds of tag, and they answer different questions. | You read | The tag | Send it back as | Never as | | -------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------- | | One record, or the response to a write | Strong, such as `"projects-12-t1791995400-3fa1"` | `If-Match` on a later write, or `If-None-Match` on a re-read | No restriction. Use the header that fits the request. | | A page of a collection | Weak, such as `W/"a1b2c3d4e5f6"` | `If-None-Match` on a re-read of the same page | `If-Match`. A page is not a record. | A tag comes with every `200` or `201` that returns a record or a page, with a `304` and with a `412`. A `204` from a `DELETE` has none, and neither does any other error. Treat a tag as opaque. Store it exactly as you received it, quotes and `W/` prefix included, and send it back unchanged. Do not parse it, and do not build one yourself. ## Re-read only what changed: `If-None-Match` Send the tag you hold. When nothing changed, the answer is `304 Not Modified` with no body. ```bash curl -i "https:///api/v2/projects/12" \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ -H 'If-None-Match: "projects-12-t1791995400-3fa1"' ``` - Repeat the exact query you used before. A record's tag also depends on the `view` and `include` you asked for: change either and you get a full `200`. Other parameters, such as `meta=actions`, do not change the tag, so a `304` does not tell you whether `meta.actions` changed. - A page's tag is weak and covers the whole page as it was rendered. Send it back with its `W/` prefix. - A `304` still uses one unit of your [rate limit](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/), whatever `view` or `include` you asked for. On a poller it is the cheapest way to ask "has this changed?". ## Refuse a stale write: `If-Match` Every `PATCH`, every `DELETE` and every action `POST` accepts `If-Match`. Send the tag of the version you based your change on. When the record changed since, nothing is written. ```bash curl -X PATCH "https:///api/v2/projects/12" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H 'If-Match: "projects-12-t1791995400-3fa1"' \ -d '{ "status": "on-hold" }' ``` The `200` carries the record's new tag. Keep it for your next write. - `If-Match` is never required. Without it, the write goes ahead whatever changed in the meantime. - `If-Match` compares versions, not query shapes. A tag from any `view` or `include` of the current version is accepted. - `If-Match: *` matches any version, so it protects nothing. Send the real tag. ### When the record changed: `412` Here a work item moved on to version 12 while you still held version 11: ```bash curl -X PATCH "https:///api/v2/work-items/812" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H 'If-Match: "work-items-812-v11-8c2e"' \ -d '{ "title": "Fix the CSV import" }' ``` Nothing is written, and the answer is `412` [precondition-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#precondition-failed). Its `ETag` header carries the current tag, `"work-items-812-v12-8c2e"`, and the body gives the current version number: ```json { "type": "https://developers.plnnrly.com/api/v2/problems/precondition-failed", "title": "Precondition failed", "status": 412, "detail": "The record changed since the version named in If-Match. Re-read it and retry.", "instance": "/api/v2/work-items/812", "request_id": "01M4ZDAAR0K7Q3XW9B2NTC5H8E", "current_version": 12 } ``` `current_version` is a number for work items only. For every other resource it is `null`, because those records have no version counter. To recover: 1. Re-read the record, unless you already hold a copy with the tag from the `412`. 2. Re-apply your change to what is there now, and check that it still makes sense. 3. Retry with the new tag. Never resend the same write with the tag from the `412` without looking first. That overwrites exactly the change the lock caught. ## How strong is the lock - Work items carry a version counter, `version`, so a write based on an older version is caught. Two writes sent at the same moment with the same tag can still both go through. - Every other resource is versioned by the second it was last changed. That is best effort: two writes in the same second share a tag, so `If-Match` cannot tell them apart. If two writers can change the same record within one second, coordinate those writes in your own code. - A tag tracks the record itself, not the rows you included. A change to an included record does not change the tag, so a `304` on a read with `include` can hide it. Read that record directly when you need it fresh. ## Which header you want | You want to say | Header | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | "Don't create this twice if my retry is a duplicate." | [Idempotency-Key](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) on the `POST` | | "Don't let me overwrite a change I haven't seen." | `If-Match` on the `PATCH`, `DELETE` or action `POST` | | "Don't send me a body I already have." | `If-None-Match` on the `GET` | They are independent and combine. An action `POST` can carry both an `Idempotency-Key` and an `If-Match`. ## Related - [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/): safe retries for `POST`. - [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/): `If-Match` on actions. - [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/): polling with `If-None-Match`. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): every problem type. --- # Idempotency Send an Idempotency-Key with a POST, so that a retry after a lost response returns the first result instead of writing twice. A `GET`, `PATCH` or `DELETE` is safe to retry: the second attempt lands on the same record and repeats the same effect, or answers `404`. With `If-Match`, a retried `PATCH` or `DELETE` whose first attempt went through can answer `412`, because your own write changed the tag. Re-read the record before you decide it failed. A `POST` is not safe to retry. When the response to a `POST` gets lost and you retry, you can create a second row. An `Idempotency-Key` header closes that gap. Every `POST` accepts one. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## Send a key Pick a new key for each write you mean to make once: a UUID or a ULID works. Store it with the work you are about to do, before you send the request. A retry must send the same key as the attempt whose answer you never saw, so a key you generate on the retry path protects nothing. ```bash curl -X POST "https:///api/v2/worklogs" \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1c9a52-7d4e-4b8a-9c21-5e6f0a7b8d13" \ -d '{ "project_id": 12, "date": "2026-10-14", "minutes": 90, "description": "Pairing on the importer" }' ``` A key is at most 255 characters. Spaces around it are trimmed, and an empty key counts as no key. A longer key is `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed), with an error for `parameter` `Idempotency-Key` and `code` `max`. ## What a retry gets back Retry with the same key and the same body, and you get the first response back: the same status and the same body, with its `Content-Type`, `Location` and `ETag` headers. One header is added: ```http Idempotency-Replayed: true ``` The first, real write does not carry it. So `Idempotency-Replayed` tells you "this already happened" without comparing bodies. `X-Request-Id` is new on every replay, because each attempt is a request of its own. ## How long and for whom a key counts - A stored response is kept for 24 hours. After that, the same key writes again. - Only successes are stored: responses below `400`. After a `4xx` or `5xx` the key is free again, so you can correct the request and retry with the same key. - While the first request is still running, a retry with the same key answers `409` [idempotency-key-in-flight](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-in-flight) with `Retry-After: 1`. That claim lasts at most 120 seconds. - A key belongs to one organisation and one token. The same key sent with another token is another key. For an app signed in with OAuth, the key belongs to the app and the user, so it survives a refresh of the access token. - A key is bound to the path and the JSON body it was first used with. The order of keys and the whitespace in the body do not matter. The same key with a different body, or on another path, is `409` [idempotency-key-reused](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-reused). ## Where a key is required These operations refuse to run without a key, because a lost `201` retried without one would report a failure, or write twice, for a write that had in fact succeeded: | Operation | Why | | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [POST /worklogs](https://developers.plnnrly.com/en/docs/api/v2/worklogs/create/) | A duplicate worklog counts the same hours twice. | | [POST /work-items/{work_item}/attachments](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/create/) | A retry without a key fails on the spent upload ticket, although the file exists. | | [POST /drive-items/{drive_item}/files](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/create/) | The same as for attachments. | Without a key, these answer `400` [idempotency-key-required](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-required). Everywhere else the key is optional, and each operation page says which of the two it is. ## When it goes wrong | Status | Problem type | Cause | What to do | | ------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- | | `409` | [idempotency-key-reused](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-reused) | The key was used before with a different body or path. | Use a new key for a new write. | | `409` | [idempotency-key-in-flight](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-in-flight) | The first request with this key has not finished. | Wait for `Retry-After`, then retry with the same key. | | `400` | [idempotency-key-required](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-required) | The operation requires a key and you sent none. | Send one. | `idempotency-key-in-flight` means your retry overtook the first attempt, not that anything failed. Retry with the same key. A new key would create the duplicate the key exists to prevent. ## Over MCP > **Note:** > AI clients write through the `api_write` tool, which neither requires nor honours an `Idempotency-Key`. A `POST /worklogs` needs no key there, and repeating a call writes again. See [MCP tools](https://developers.plnnrly.com/en/docs/mcp/tools/). ## Idempotency is not concurrency An idempotency key stops a `POST` from being applied twice. It does not stop a `PATCH` from overwriting someone else's change: that is `If-Match`, a different header with a different failure. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). ## Related - [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/): `If-Match` and `If-None-Match`. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): every problem type. - [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/): the two registrations that require a key. - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/): every numeric limit in one table. --- # Actions and state changes How the action endpoints work, which ones you may call on a record, how to make them safe to retry, and what each outcome means. Some changes are more than a field update: starting a sprint, moving a work item through its workflow, restoring a deleted record. The API calls these actions. Each one is a `POST` to `/{resource}/{id}/actions/{verb}`, and each returns the record as it is afterwards. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## The actions - [POST Restore a deleted customer subscription](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/restore/): `/customer-subscriptions/{customer_subscription}/actions/restore` - [POST Restore a deleted customer](https://developers.plnnrly.com/en/docs/api/v2/customers/restore/): `/customers/{customer}/actions/restore` - [POST Restore an archived drive item](https://developers.plnnrly.com/en/docs/api/v2/drive-items/restore/): `/drive-items/{drive_item}/actions/restore` - [POST Restore a deleted event](https://developers.plnnrly.com/en/docs/api/v2/events/restore/): `/events/{event}/actions/restore` - [POST Restore a deleted project](https://developers.plnnrly.com/en/docs/api/v2/projects/restore/): `/projects/{project}/actions/restore` - [POST Restore a deleted sales invoice](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/restore/): `/sales-invoices/{sales_invoice}/actions/restore` - [POST Complete a sprint](https://developers.plnnrly.com/en/docs/api/v2/sprints/complete/): `/sprints/{sprint}/actions/complete` - [POST Start a sprint](https://developers.plnnrly.com/en/docs/api/v2/sprints/start/): `/sprints/{sprint}/actions/start` - [POST Restore a deleted subscription product](https://developers.plnnrly.com/en/docs/api/v2/subscription-products/restore/): `/subscription-products/{subscription_product}/actions/restore` - [POST Restore a deleted subscription type](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/restore/): `/subscription-types/{subscription_type}/actions/restore` - [POST Move a work item to a position](https://developers.plnnrly.com/en/docs/api/v2/work-items/move/): `/work-items/{work_item}/actions/move` - [POST Transition a work item](https://developers.plnnrly.com/en/docs/api/v2/work-items/transition/): `/work-items/{work_item}/actions/transition` Some actions take a JSON body and some take none. Each operation page gives the body in full. ## Which actions you may call Add `meta=actions` to the single read of a record: ```bash curl -G "https:///api/v2/work-items/ACM-12" \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ --data-urlencode "meta=actions" ``` The `meta` member of the response lists the verbs you may call on that record, in alphabetical order: ```json { "actions": ["move", "transition"] } ``` - The list reflects your token's scope, your role's permissions and your access to the record. When you may call none, `meta.actions` is left out. - It answers "may I?", not "will it succeed?". It does not know the body you will send or every rule about the record's state. A listed `transition` can still be refused for an unreachable status, and a sprint lists both `start` and `complete` whether or not it is running. - `meta=actions` works on the single read of every resource that has actions. Anywhere else it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). ## Make an action safe to retry - Send `If-Match` with the tag you last read. When the record changed since, the answer is `412` and nothing happens. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). - `Idempotency-Key` is optional, but it is what makes a retry after a lost response safe. Without one, the retry can fail although the first attempt worked: with the same `If-Match` it can answer `412`, because that attempt changed the record, and starting or completing a sprint answers `409`. With a key, the retry returns the first `200`. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). ## Outcomes | You get | When | | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `200` | The action ran. The body is the record in the `detail` view, and the `ETag` header is its new tag. | | `200`, nothing changed | A `move` to where the item already is. Nothing is written and its `version` stays the same, so a retry is safe. | | `200` with an `already_in_target_state` warning | You restored a record that was not deleted. Only restore actions send this warning. | | `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict) | The record's state does not allow the action now. `conflict.reason` says why. | | `412` [precondition-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#precondition-failed) | The record changed since the tag you sent in `If-Match`. | | `422` | The body is wrong, for example a sprint from another project. `errors[].pointer` names the field. | A `transition` to the status the item already has also answers `200` and leaves the status as it is. It still raises `version` by one, so any tag you hold for the item goes stale. These are the reasons an action gives in `conflict.reason`: | Action | `conflict.reason` | | --------------------------------- | -------------------------------------------------------------------------------- | | Work item `transition` and `move` | `illegal_transition`, with `from_workflow_status_id` and `to_workflow_status_id` | | Sprint `start` | `already_started`, `already_completed` | | Sprint `complete` | `already_completed`, `not_started` | A refused transition looks like this: ```json { "type": "https://developers.plnnrly.com/api/v2/problems/state-conflict", "title": "State conflict", "status": 409, "detail": "The target status is not reachable from this work item's current status.", "instance": "/api/v2/work-items/ACM-12/actions/transition", "request_id": "01M4ZDC3F2P8V6YQ1RJ4M7XA0S", "conflict": { "reason": "illegal_transition", "from_workflow_status_id": 3, "to_workflow_status_id": 9 } } ``` ## Work items: transition or move `transition` changes the status and nothing else: ```bash curl -X POST "https:///api/v2/work-items/ACM-12/actions/transition" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H 'If-Match: "work-items-812-v11-8c2e"' \ -d '{ "workflow_status_id": 9 }' ``` The target must be reachable from the current status: the project's workflow needs a transition from the one to the other. To see which statuses are reachable, list [GET /workflow-transitions](https://developers.plnnrly.com/en/docs/api/v2/workflow-transitions/list/) with `filter[from_status_id]` set to the item's current `workflow_status_id`. `move` does what a drag on the board or in the backlog does. It places the item in a list, and can change its status and its sprint in the same call: | Field | Required | What it does | | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list` | yes | `board`: the column of the item's status, written to `column_order`. `backlog`: the item's sprint, or the backlog when it is in no sprint, written to `order`. | | `workflow_status_id` | no | The new status, checked the same way as `transition`. Leave it out to keep the status. | | `sprint_id` | no | A sprint on the same project that is not completed, or `null` for the backlog. Leave it out to keep the sprint. On a project that works in sprints, an item needs a `task_id` before it can join the running sprint. | | `after_work_item_id` | no | The item to land directly below, by id or issue key. `null` puts the item at the top. Left out, the item keeps its place, or goes to the end of a list it is new to. | ```bash curl -X POST "https:///api/v2/work-items/ACM-12/actions/move" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "list": "board", "workflow_status_id": 9, "after_work_item_id": "ACM-7" }' ``` - The item named in `after_work_item_id` must be in the target list. Otherwise the move is `422` at `/after_work_item_id`. - On a project that works in sprints, the board shows the running sprint only. So `list: board` needs the item to end up in that sprint; otherwise the move is `422` at `/list`. - A move that changes something raises the item's `version` by one. Other items in the list keep their `version`, even when they shift. Use `transition` for a status change on its own. Use `move` for anything that changes where the item sits. ## Sprints: start and complete `start` takes no body. It sets `is_active` to `true` and stamps `started_at`. A project runs one sprint at a time: starting a second one is `409` with `already_started`, and `conflict.sprint_id` names the sprint that is running. Complete that one first. `complete` needs `unresolved_work_items`, which decides where the work items that are not resolved go: | Value | The unresolved work items | | --------------------- | ------------------------------------------------------------------------------------------------- | | `move_to_next_sprint` | Move to the sprint in `next_sprint_id`: another sprint on the same project that is not completed. | | `move_to_backlog` | Go back to the project backlog. | | `leave_on_sprint` | Stay on the completed sprint. | ```bash curl -X POST "https:///api/v2/sprints/7/actions/complete" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6b0e2f1d-93a4-4c57-8e1b-2d7f4a9c0e35" \ -d '{ "unresolved_work_items": "move_to_next_sprint", "next_sprint_id": 8 }' ``` Resolved work items always stay on the completed sprint, so it keeps its history. Every work item that moves gets a new `version`: re-read any you hold a tag for. ## Restore Most records that a `DELETE` archives come back with `POST …/actions/restore`. See [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/) for which ones, and for what a restore does not bring back. ## Over MCP AI clients call an action with the `api_write` tool, as a `POST` to the action's path, such as `work-items/ACM-12/actions/transition`. The tool takes no headers, so neither `If-Match` nor `Idempotency-Key` applies there. The other outcomes on this page are the same. ## Related - [Move work items through the workflow](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/work-item-workflow/): a worked example. - [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/): what restore brings back. - [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/): `If-Match` in full. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict): `state-conflict` and the other problem types. --- # Deleting and restoring What a DELETE does to each resource, how to read and restore archived records, what can block a delete and who may delete. A `DELETE` does one of three things, depending on the resource. Check which before you delete anything you may want back. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## Three behaviours - **Archived, can be restored.** The record stays, with a `deleted_at` timestamp, and drops out of lists and reads. `POST …/actions/restore` brings it back. - **Archived, cannot be restored.** The record stays, with a `deleted_at` timestamp, but it has no restore action. These are lines inside another record, such as the rows of a sales invoice. To get one back, create it again. - **Deleted permanently.** The record is gone, and the API has no way to bring it back. Which resource behaves how, taken from the reference: - **Archived, can be restored:** [`/customer-subscriptions`](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/customer-subscriptions/restore/)), [`/customers`](https://developers.plnnrly.com/en/docs/api/v2/customers/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/customers/restore/)), [`/drive-items`](https://developers.plnnrly.com/en/docs/api/v2/drive-items/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/drive-items/restore/)), [`/events`](https://developers.plnnrly.com/en/docs/api/v2/events/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/events/restore/)), [`/projects`](https://developers.plnnrly.com/en/docs/api/v2/projects/delete/) (accepts `force=true`; [POST Restore](https://developers.plnnrly.com/en/docs/api/v2/projects/restore/)), [`/sales-invoices`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/restore/)), [`/subscription-products`](https://developers.plnnrly.com/en/docs/api/v2/subscription-products/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/subscription-products/restore/)), [`/subscription-types`](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/delete/) ([POST Restore](https://developers.plnnrly.com/en/docs/api/v2/subscription-types/restore/)) - **Archived, cannot be restored:** [`/sales-invoice-rows`](https://developers.plnnrly.com/en/docs/api/v2/sales-invoice-rows/delete/), [`/subscription-items`](https://developers.plnnrly.com/en/docs/api/v2/subscription-items/delete/), [`/subscription-type-items`](https://developers.plnnrly.com/en/docs/api/v2/subscription-type-items/delete/) - **Deleted permanently:** [`/absences`](https://developers.plnnrly.com/en/docs/api/v2/absences/delete/), [`/budget-groups`](https://developers.plnnrly.com/en/docs/api/v2/budget-groups/delete/), [`/budgets`](https://developers.plnnrly.com/en/docs/api/v2/budgets/delete/), [`/checkins`](https://developers.plnnrly.com/en/docs/api/v2/checkins/delete/), [`/comments`](https://developers.plnnrly.com/en/docs/api/v2/comments/delete/), [`/company-branches`](https://developers.plnnrly.com/en/docs/api/v2/company-branches/delete/), [`/contact-roles`](https://developers.plnnrly.com/en/docs/api/v2/contact-roles/delete/), [`/contacts`](https://developers.plnnrly.com/en/docs/api/v2/contacts/delete/), [`/customer-categories`](https://developers.plnnrly.com/en/docs/api/v2/customer-categories/delete/), [`/drive-documents`](https://developers.plnnrly.com/en/docs/api/v2/drive-documents/delete/), [`/drive-folders`](https://developers.plnnrly.com/en/docs/api/v2/drive-folders/delete/), [`/free-days`](https://developers.plnnrly.com/en/docs/api/v2/free-days/delete/), [`/furlough-balance-mutations`](https://developers.plnnrly.com/en/docs/api/v2/furlough-balance-mutations/delete/), [`/holidays`](https://developers.plnnrly.com/en/docs/api/v2/holidays/delete/), [`/labels`](https://developers.plnnrly.com/en/docs/api/v2/labels/delete/), [`/notes`](https://developers.plnnrly.com/en/docs/api/v2/notes/delete/), [`/project-links`](https://developers.plnnrly.com/en/docs/api/v2/project-links/delete/), [`/sprints`](https://developers.plnnrly.com/en/docs/api/v2/sprints/delete/), [`/subtasks`](https://developers.plnnrly.com/en/docs/api/v2/subtasks/delete/), [`/tasks`](https://developers.plnnrly.com/en/docs/api/v2/tasks/delete/), [`/timers`](https://developers.plnnrly.com/en/docs/api/v2/timers/delete/), [`/todos`](https://developers.plnnrly.com/en/docs/api/v2/todos/delete/), [`/uploads`](https://developers.plnnrly.com/en/docs/api/v2/uploads/delete/), [`/work-item-links`](https://developers.plnnrly.com/en/docs/api/v2/work-item-links/delete/), [`/work-items`](https://developers.plnnrly.com/en/docs/api/v2/work-items/delete/), [`/work-types`](https://developers.plnnrly.com/en/docs/api/v2/work-types/delete/), [`/workflow-statuses`](https://developers.plnnrly.com/en/docs/api/v2/workflow-statuses/delete/), [`/workflow-transitions`](https://developers.plnnrly.com/en/docs/api/v2/workflow-transitions/delete/), [`/workflows`](https://developers.plnnrly.com/en/docs/api/v2/workflows/delete/), [`/worklogs`](https://developers.plnnrly.com/en/docs/api/v2/worklogs/delete/) Each `DELETE` operation page says what else happens: what is removed with the record, what is detached and what is kept. Read it before you delete in bulk. ## Read archived records Archived records are hidden until you ask for them with `trashed`: | `trashed` | On a list | On a single read | | ----------------------- | -------------------------- | -------------------------------------- | | `without` (the default) | Live records only. | An archived record is `404`. | | `with` | Live and archived records. | Live or archived. | | `only` | Archived records only. | Archived only. A live record is `404`. | - To edit an archived record, send `trashed=with` with the `PATCH`. - A list inside an archived record, such as the tasks of an archived project, needs `trashed=with` as well. - `trashed` works only where a record can be archived. Anywhere else it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). ## Restore ```bash curl -X POST "https:///api/v2/projects/12/actions/restore" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` - The answer is `200` with the record in the `detail` view. A restore takes no `trashed`: the action finds the archived record itself. - Restoring needs a token with **Write** for the resource, plus the permissions the operation page names. Restoring a record that is not archived changes nothing, so a retry is safe. It answers `200`, and the `meta` member of the response carries a warning: ```json { "warnings": [ { "code": "already_in_target_state", "detail": "This project was not deleted." } ] } ``` A restore is not always a full undo, so read the restore operation before you rely on it. Two examples: - Deleting a customer also archives its projects. [Restoring the customer](https://developers.plnnrly.com/en/docs/api/v2/customers/restore/) does not bring them back: restore each project. - [Deleting a sales invoice](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/delete/) releases the hours that were invoiced on it. [Restoring it](https://developers.plnnrly.com/en/docs/api/v2/sales-invoices/restore/) brings back the invoice and its rows, but not the link to those hours, so they can be invoiced a second time. ## Delete a project [DELETE /projects/{project}](https://developers.plnnrly.com/en/docs/api/v2/projects/delete/) archives the project. Its notes, Drive items and tags stay in place, so a restore brings it back intact. `force=true` deletes the project permanently. Its notes are deleted, its Drive items are archived and its tags are detached. A project that is already archived can be deleted permanently too. ```bash curl -X DELETE "https:///api/v2/projects/12?force=true" \ -H "Authorization: Bearer " \ -H 'If-Match: "projects-12-t1791995400-3fa1"' ``` - `force` is accepted on this operation only. On any other `DELETE` it is `400` [unsupported-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unsupported-parameter). - A project with logged time cannot be deleted at all, archived or permanently. The answer is `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict) with `has_logged_time`. Move or delete its worklogs first. ## What can block a delete When the record's state forbids the delete, the answer is `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict), and `conflict.reason` says why: | `conflict.reason` | Means | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `has_logged_time` | Time has been logged against the record, such as a project or a budget. | | `has_children` | The record still has children, such as a work item with child work items, or a Drive folder that still holds live items or subfolders. | | `locked_by_review` | The worklog's hours are under review. | | `locked_by_invoice` | The worklog's hours have been invoiced. | | `locked_by_legacy` | The sales invoice predates the current invoicing flow. | | `locked_by_sync` | The sales invoice has been synced to the accounting system. | Some rules answer `403` [forbidden-by-policy](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#forbidden-by-policy) instead, such as deleting a work type that work items still use. Each `DELETE` operation page lists what applies to it. ## Who may delete - Every `DELETE` needs a token with the **Delete** scope for that resource. Without it, the answer is `403` [insufficient-token-scope](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-token-scope). See [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/). - Most deletes also need a permission from your role, and each operation page names it. A few depend on the record instead: you may delete your own worklogs and timers, for example. - Apps signed in with OAuth cannot call a `DELETE`: an OAuth token never holds the **Delete** scope. They can restore, because restoring needs **Write**. See [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). - AI clients over MCP can delete only while an administrator has switched on **Allow destructive actions**, and only after you confirm the delete. See [Permissions and safety](https://developers.plnnrly.com/en/docs/mcp/permissions-and-safety/). ## Deleting twice - Deleting an archived record again answers `204`, so retrying an archive is safe. If the retry carries the same `If-Match` and the first attempt went through, it can answer `412` instead: read the record with `trashed=with`, and a `deleted_at` means your delete worked. - A permanent delete answers `404` the second time. When you retry a delete whose response you lost, treat that `404` as success. - Send `If-Match` to delete only the version you looked at. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). ## Deletions and sync A permanent delete leaves nothing to read back: a copy you keep in sync notices it only because the record is missing. Archived records stay readable with `trashed=with` and carry `deleted_at`. See [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/). ## Related - [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/): how restore and the other actions behave. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): the **Delete** scope. - [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/): noticing deletions. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): every problem type. --- # Files: upload and download Attach a file to a work item or a Drive item in three steps, and download attached files through a signed link. `/api/v2` accepts JSON and nothing else, so a file reaches plnnrly in three steps. The middle step goes to a different URL. 1. `POST /api/v2/{parent}/uploads` returns a ticket with an `upload_url`. 2. `PUT` the raw bytes to that `upload_url`, outside `/api/v2`. 3. `POST /api/v2/{parent}/{files}` turns the ticket into a file. The ticket keeps this safe: the server decides where the bytes go, and no field in any request can change that. > **Warning:** > Uploads need a personal access token, and the same token for all three steps. An OAuth access token can create a ticket but gets `401` on step 2. AI clients over MCP cannot upload at all. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## Where it works | Parent | Step 1 | Step 3 | | ---------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Work item | [POST /work-items/{work_item}/uploads](https://developers.plnnrly.com/en/docs/api/v2/work-items/uploads/create/) | [POST /work-items/{work_item}/attachments](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/create/) | | Drive item | [POST /drive-items/{drive_item}/uploads](https://developers.plnnrly.com/en/docs/api/v2/drive-items/uploads/create/) | [POST /drive-items/{drive_item}/files](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/create/) | ## Step 1: create a ticket Declare the file's name and size: ```bash curl -X POST "https:///api/v2/work-items/812/uploads" \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "file_name": "quarterly-report.pdf", "byte_size": 184320 }' ``` | Field | Required | Notes | | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file_name` | yes | At most 255 characters. Control characters are removed, and `#`, `/`, `\` and spaces become hyphens, so the name you read back can differ from the one you sent. | | `byte_size` | yes | 1 to 10,485,760 (10 MB). A declaration only: the server counts the bytes that actually arrive. | | `checksum_sha256` | no | 64 lowercase hex characters. When you send it, step 2 refuses bytes with a different SHA-256. | A file name is refused with `422` when any part after its first dot is a server-script extension, such as `php`, `phtml`, `phar`, `cgi`, `pl`, `asp` or `jsp`. So `report.php.pdf` is refused. A name that is empty once cleaned is refused too. Both happen here, before you send any bytes. The `201` is the ticket. Its `Location` header is the ticket's absolute URL, the same as `links.self`: ```json { "data": { "id": 3141, "work_item_id": 812, "drive_item_id": null, "collection": "attachments", "file_name": "quarterly-report.pdf", "byte_size": 184320, "checksum_sha256": null, "received_byte_size": null, "received_checksum_sha256": null, "mime_type": null, "status": "pending", "upload_url": "", "expires_at": "2026-10-15T09:42:00Z", "register_by": null, "uploaded_at": null, "committed_at": null, "media_id": null, "created_at": "2026-10-15T09:12:00Z", "updated_at": "2026-10-15T09:12:00Z", "links": { "self": "https://acme.plnnrly.com/api/v2/uploads/3141" } } } ``` - The ticket is yours alone. Somebody else's ticket is `404`, and `422` when you try to register it. - `expires_at` is 30 minutes after the ticket was created. It is the deadline for step 2. - You can hold 10 open tickets at a time, across all your tokens. Open means pending, uploaded or being registered. The eleventh is `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict) with `too_many_pending_uploads`: register or delete a ticket first. A ticket whose deadline passed keeps counting until the hourly sweep marks it `expired`. Delete it to free the slot at once. - A deleted work item or an archived Drive item is `404`. ## Step 2: PUT the bytes This is the one request that does not go to `/api/v2`. Send the raw file as the body, to `upload_url` exactly as you received it. The URL is opaque: do not parse it, change it or build it yourself. ```bash curl -X PUT "" \ -H "Authorization: Bearer " \ -H "Content-Type: application/octet-stream" \ --data-binary @quarterly-report.pdf ``` - Send the file itself, not a form. The body is stored exactly as it arrives, so a multipart envelope would become part of the file. - `Content-Type` is not read. `application/octet-stream` stops curl from labelling the body as a form. - A ticket takes its bytes once. A second `PUT` is `409`. | Status | When | | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `204` | The bytes arrived. The ticket is now `uploaded`. | | `400` | The body length does not match `Content-Length`. | | `401` | No token, or a token that is not a personal access token. | | `403` | The `upload_url` has expired or was changed, the token is not the one that created the ticket, or you may no longer attach files to the parent. | | `404` | Not your ticket. | | `408` [request-timeout](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#request-timeout) | 30 seconds without data, or the upload took longer than 120 seconds. | | `409` | The ticket already took its bytes or was deleted, or the parent no longer exists. | | `413` [payload-too-large](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#payload-too-large) | More than 10 MB, or an empty body with a `Content-Length` above 0. | | `422` | The bytes do not match `checksum_sha256`, or the body is empty. | | `429` | More than 3 uploads at once on one token (with `Retry-After: 5`), or more than 20 `PUT` requests per minute on one token, failed ones included. | | `500` | Storage failed. The ticket stays open: retry the `PUT`. | Errors from this URL carry `type`, `title`, `status` and `detail`, but no `request_id`. A `401`, an expired or changed `upload_url` and the per-minute limit answer with a plain JSON `message` instead. Switch on the status code. ## Step 3: register the ticket ```bash curl -X POST "https:///api/v2/work-items/812/attachments" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9d4b7e20-1c3f-4a86-b5e9-0f2a6c8d1e47" \ -d '{ "upload_id": 3141 }' ``` - `upload_id` is the only field. Any other, such as `file_name` or `mime_type`, is `422`: the file's name, size and type come from the ticket and the bytes. - `Idempotency-Key` is required here. Without one the answer is `400`. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). - Before it creates anything, the server hashes the stored bytes again and compares them with what arrived. - The `201` is the file in the `detail` view. There is no `Location` header: a file has no URL of its own in the API. - The file's `mime_type` follows the extension of its name. Only when the name has no extension, or one the server does not know, does it fall back to the type detected from the bytes. The ticket's `mime_type` is always the detected type, so the two can differ. - A file cannot be changed or deleted through the API. Remove it in plnnrly itself. When registering fails: | Status | Reason | What to do | | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | `409` | `upload_not_received` | Do step 2 first. | | `409` | `upload_expired` | The bytes arrived more than 30 minutes ago, or the ticket expired. Start again with a new ticket. | | `409` | `upload_revoked` | The ticket was deleted. Start again. | | `409` | `upload_already_registered` | The ticket is already a file. `conflict.media_id` names it. | | `409` | `upload_registration_in_progress` | Another registration of this ticket is running. Retry after `Retry-After`. | | `409` | `upload_object_missing`, `upload_object_changed` | The staged bytes are gone or no longer match. Start again. | | `409` | `upload_token_mismatch` | Use the token that created the ticket. | | `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) | `/upload_id`, code `exists` | Not your ticket, or it was already pruned. Start again with a new ticket. | | `422` [unprocessable-relationship](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unprocessable-relationship) | `/upload_id`, code `exists` | The ticket was created for a different parent. | Expired tickets are marked by a sweep that runs every hour. Until it runs, a ticket whose step 2 deadline has passed still reads `pending`, and registering it answers `upload_not_received`. After `upload_object_missing` or `upload_object_changed`, the ticket can stay `committing`. A retry then answers `upload_registration_in_progress` however long you wait, and the ticket cannot be deleted. It counts towards your 10 open tickets until the hourly sweep marks it `expired`, an hour or more after its `expires_at`. ## Check on a ticket [GET /uploads/{upload}](https://developers.plnnrly.com/en/docs/api/v2/uploads/get/) reads one of your tickets: its status, the size and SHA-256 of the bytes that arrived, the type detected from them, both deadlines and, once registered, the `media_id` of the file. | `status` | Means | | ------------ | ------------------------------------------------------------------------------------ | | `pending` | Waiting for the bytes. `upload_url` is present in this status only. | | `uploaded` | The bytes arrived. Register them before `register_by`, 30 minutes after they landed. | | `committing` | A registration is running. | | `committed` | Done. `media_id` names the file. | | `expired` | A deadline passed. Start again with a new ticket. | | `revoked` | The ticket was deleted before it was registered. | A registered ticket is kept for 24 hours, an expired or revoked one for 7 days. After that it is `404`, so read `media_id` soon after you register. [DELETE /uploads/{upload}](https://developers.plnnrly.com/en/docs/api/v2/uploads/delete/) revokes a ticket you no longer need: the staged bytes are deleted, the ticket becomes `revoked` and the answer is `204`. It needs a token with the **Delete** scope. A ticket that is registered, or being registered, is `409`. ## Download a file 1. List the files with `include=url`, on [GET /work-items/{work_item}/attachments](https://developers.plnnrly.com/en/docs/api/v2/work-items/attachments/list/) or [GET /drive-items/{drive_item}/files](https://developers.plnnrly.com/en/docs/api/v2/drive-items/files/list/). 2. GET each file's `url` with the same bearer token. ```bash curl -G "https:///api/v2/work-items/812/attachments" \ -H "Authorization: Bearer " \ --data-urlencode "include=url" ``` ```bash curl -L "" \ -H "Authorization: Bearer " \ -o quarterly-report.pdf ``` - The `url` works only together with your token. - The `url` does not expire. `url_expires_at` is reserved for later and is always `null`. - The answer can be a redirect to a short-lived storage link. Follow it: that link carries its own signature and needs no token. `curl -L` follows it and does not send your token on to another host. - A `null` `url` with a `signed_url_unavailable` warning means files in this organisation cannot be downloaded through the API. They are still available in plnnrly itself. ## Related - [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/): why step 3 requires a key. - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/): the problem types above. - [What the API cannot do](https://developers.plnnrly.com/en/docs/api/v2/guides/known-limitations/): uploads over OAuth and MCP, and deleting files. - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/): the upload limits next to all the others. --- # Rate limits What each request costs, the ceilings per token and per organisation, and the headers that tell you how much budget is left. Every request to `/api/v2` spends units from rate buckets. You can work out what a request costs before you send it, and every response tells you how much budget is left. ## Ceilings Every bucket counts over a window of 60 seconds. | Bucket | Read units | Write units | | ---------------------------------------------------- | ---------- | ----------- | | Per token | 600 | 120 | | Per organisation, shared by all its tokens and users | 3000 | 600 | - Each token has its own budget, but every token in your organisation also spends from the organisation's bucket. Another token can therefore lower what you have left. - For an app that signs in with OAuth, the per-token budget belongs to the app and the user who signed in. It carries over when the access token is refreshed. Before the token is checked, a per-IP limit applies as well: 600 requests per minute per IP address and host, and 1200 requests per minute per IP address. It counts requests, not units, and it never shows in the `RateLimit` headers. These ceilings are the current budget. Read `RateLimit-Policy` at runtime instead of hard-coding them. ## Read and write buckets - A `GET` spends its cost from the read buckets. - A `POST`, `PATCH` or `DELETE` spends its cost from the write buckets, plus 1 unit from the read buckets. A write therefore needs room in both, and an empty read bucket blocks writes too. Each operation page in the reference shows the bucket it spends from. ## What a request costs ```text cost = base 1, or 3 for view=detail on a collection + each include path +1, or +3 when the path is a list + q +2 when a search term is sent + with_total=1 +2 ``` | Part | Units | When it applies | | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------ | | Base | 1 | Every request. | | Base with `view=detail` | 3 instead of 1 | Only on a collection read. On a single record and on a write, `view=detail` costs nothing extra. | | Include path, to-one | +1 per path | The path inlines one related record, such as `assignee`. | | Include path, to-many | +3 per path | The path inlines a list of records, such as `labels`: up to 25, or 50 for `tags`. | | `q` | +2 | A search term is sent. | | `with_total=1` | +2 | A collection read asks for the total. | A `DELETE` always costs 1. Name each include path once. A path you repeat, such as `include=labels,labels`, is loaded once but charged every time it appears. ### Worked example `GET /work-items` takes the to-many include paths `comments`, `drive_items`, `labels` and `subtasks`, and to-one paths such as `assignee` and `sprint`. | Request | Cost | | ------------------------------------------------------------------------------------------- | -------------------------- | | `GET /work-items` | 1 | | `?view=detail` | 3 | | `?include=assignee` | 1 + 1 = 2 | | `?include=labels` | 1 + 3 = 4 | | `?include=assignee,sprint,labels` | 1 + 1 + 1 + 3 = 6 | | `?q=invoice&with_total=1` | 1 + 2 + 2 = 5 | | `?view=detail&q=invoice&with_total=1&include=comments,drive_items,labels,subtasks,assignee` | 3 + 2 + 2 + 4 × 3 + 1 = 20 | With each path named once, the last request is as expensive as `GET /work-items` gets: it names as many include paths as one request may, and every to-many path is among them. [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/) has the maximum. ### What costs exactly one unit, and what costs nothing The extra units are charged only on a successful `2xx` response that was not replayed. So these cost exactly 1 unit, whatever the request asked for: - a `304 Not Modified` - a replayed `POST` that returns the stored response for its `Idempotency-Key` - an error response, such as a `400` or a `422` These cost no units at all: - a `429` - a path or method that does not exist - a request refused before or at the token check: an address that serves no API, a switched-off REST API, or a missing or invalid token. It still counts towards the per-IP limit. ## The headers Every response carries the current state of your budget, errors and `429` included: ```http RateLimit: limit=600, remaining=598, reset=42 RateLimit-Policy: 600;w=60 ``` | Header | Means | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RateLimit` | `limit` is the ceiling of the tightest bucket this request spent from: the one with the fewest units left. `remaining` is what is left in it, after this request's own cost. `reset` is the number of seconds until it refills. | | `RateLimit-Policy` | The same bucket's ceiling and window in seconds, as `;w=`. | These headers use the syntax of an earlier draft of the IETF RateLimit header fields. Later drafts changed the syntax, so parse the form shown here. Three older headers are still sent, but they are deprecated and stop on 2027-09-01: - `X-RateLimit-Limit` - `X-RateLimit-Remaining` - `X-RateLimit-Reset` Move to `RateLimit` now. Note that `X-RateLimit-Reset` is a Unix timestamp, while the `reset` in `RateLimit` counts seconds from now. ## When you run out You get `429` [rate-limit-exceeded](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#rate-limit-exceeded): ```json { "type": "https://developers.plnnrly.com/api/v2/problems/rate-limit-exceeded", "title": "Rate limit exceeded", "status": 429, "detail": "The rate limit for this caller has been exhausted. Retry after the interval in the Retry-After header.", "instance": "/api/v2/work-items", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "rate_limit": { "limit": 600, "retry_after": 42 } } ``` `rate_limit.limit` is the ceiling that ran out. `rate_limit.retry_after` and the `Retry-After` header give the seconds until it refills. Wait that long: a retry that arrives earlier is refused again. A `429` is always safe to retry, because the request did not run. On a `POST`, retry with the same [Idempotency-Key](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) anyway. ## MCP and OAuth - **MCP.** Calls through the MCP server do not spend these units. The MCP server allows 60 requests per minute per user, and every MCP request counts, not only tool calls. See [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/). - **OAuth.** Registering an app and getting or refreshing its tokens have limits of their own. See [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). ## Spending less - **Ask for full pages.** Send `per_page=100`: a page costs the same whether it holds 1 row or 100. With a to-many include, the maximum is 25 rows per page. - **Do not ask for a total you will not read.** `with_total=1` adds 2 units, and `links.next` already tells you whether there is another page. - **Prefer an include over follow-up reads.** A to-one path adds 1 unit for a whole page of rows. A to-many path adds 3 and limits the page to 25 rows, so compare it with reading that list separately. - **Stay on the summary view while paging.** `view=detail` triples the cost of a collection read. - **Honour the cache lifetime.** A few configuration reads, such as labels and work types, declare `Cache-Control: private, max-age=60`, and discovery declares `private, max-age=300`. The operation page says so. Re-reading them sooner spends units for nothing. - **Revalidate instead of reading again.** Send `If-None-Match`: a `304` costs 1 unit, even for a request that would otherwise cost more. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). ## Related - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/) - [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/) - [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/) --- # Errors and retries Every failure is a problem document. Switch on the problem type, never on the status code alone, and retry only what is safe to retry. Every error from `/api/v2` is a problem document (RFC 9457), sent as `application/problem+json`. This page lists every problem type with its causes and the fix, and says which errors are worth retrying. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "3 fields failed validation.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "errors": [ { "pointer": "/date", "code": "date_format", "detail": "The date does not match the format Y-m-d." }, { "pointer": "/minutes", "code": "min", "detail": "The minutes must be at least 1." }, { "pointer": "/description", "code": "required", "detail": "The description field is required." } ] } ``` ## Reading a problem document | Member | What it holds | | ------------ | ------------------------------------------------------------------------------------------- | | `type` | The problem type. Switch on this. Its last segment is the slug this page uses as an anchor. | | `title` | A short name, fixed per type. Do not parse it. | | `status` | The HTTP status, repeated in the body. | | `detail` | An explanation for a person. It may name values from your request. Do not parse it. | | `instance` | The request path, without the query string. | | `request_id` | The same id as the `X-Request-Id` response header. Send it when you contact support. | Some types add members of their own. They are listed with each type below, and the [problems schema](https://developers.plnnrly.com/en/docs/api/v2/schemas/problems/) documents every member field by field. Switch on `type`, not on the status code. Several problem types share `403`, and several share `409`, each with a different fix. The `type` values are stable identifiers. Open one in a browser and it lands on its entry on this page. **Language.** `title`, `detail` and the `detail` of every `errors` entry are always in English, whatever language your organisation or your user account uses. **Over MCP.** The `api_read` and `api_write` tools return the same problem documents, as tool errors. ## Problem types at a glance There are 26 problem types, plus the ones only the file upload sends, listed at the end of this page. | Status | Problem types | What to do | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `400` | [invalid-query-parameter](#invalid-query-parameter), [unsupported-parameter](#unsupported-parameter), [invalid-cursor](#invalid-cursor), [malformed-request](#malformed-request), [idempotency-key-required](#idempotency-key-required) | Fix the request. Never send it again unchanged. | | `401` | [unauthenticated](#unauthenticated), [token-expired](#token-expired) | Check the token and the host. | | `403` | [insufficient-token-scope](#insufficient-token-scope), [insufficient-permission](#insufficient-permission), [forbidden-by-policy](#forbidden-by-policy) | Read the type: a token fix, a role fix, or a record you may not touch. | | `404` | [endpoint-not-found](#endpoint-not-found), [resource-not-found](#resource-not-found) | Tell "nothing answers here" apart from "no such record". | | `405` | [method-not-allowed](#method-not-allowed) | Use a method from the `Allow` header. | | `406` | [not-acceptable](#not-acceptable) | Leave `Accept` out, or allow `application/json`. | | `408` | [request-timeout](#request-timeout) | File upload only. Send the file again. | | `409` | [duplicate-resource](#duplicate-resource), [state-conflict](#state-conflict), [idempotency-key-reused](#idempotency-key-reused), [idempotency-key-in-flight](#idempotency-key-in-flight) | Depends on the type: some are about the record, others about the idempotency key. | | `410` | [endpoint-retired](#endpoint-retired) | Move to the replacement in `replaced_by`. | | `412` | [precondition-failed](#precondition-failed) | Re-read, re-apply your change, and retry. | | `413` | [payload-too-large](#payload-too-large) | File upload only. The file is too large. | | `415` | [unsupported-media-type](#unsupported-media-type) | Send `Content-Type: application/json`. | | `422` | [validation-failed](#validation-failed), [unprocessable-relationship](#unprocessable-relationship) | Fix the body. `errors` points at each fault. | | `429` | [rate-limit-exceeded](#rate-limit-exceeded) | Wait the seconds in `Retry-After`. | | `500` | [internal-error](#internal-error) | Retry if it is safe, then contact support. | | `503` | [tenant-unavailable](#tenant-unavailable) | Retry a few times. If it persists, ask an administrator. | ## 400 Bad Request ### Invalid query parameter A query parameter is wrong in one of two ways: - Its name is unknown: a typo, or a parameter this API does not have, such as `?limit=` or `?version=`. - Its name is known, but it is used wrongly here: a bad value, a filter, operator, sort key or include name this resource does not declare, too many values, or a collection-only parameter such as `sort` sent to a single record. Carries `errors`, one entry per fault, each with the `parameter` and a `code`. The `detail` of an entry lists what is allowed. No query parameter is ever silently ignored. A misspelt filter is refused, so it can never widen your results without you noticing. ### Unsupported parameter The parameter is one this API knows, but this operation never accepts it: - `fields`: there are no sparse fieldsets. Choose `view=summary` or `view=detail` instead. - `url_ttl_minutes`: reserved, and accepted on no operation. - `trashed` on a resource that does not archive, `force` outside a `DELETE`, `q` or `include` where the resource has none, `meta` on a resource without actions, `include` or `view` on a `DELETE`, and `with_total` on a resource that always or never counts. Carries `errors`. Faults are checked in stages, so one response may not name every fault in your request. An unknown name is reported first. Fix what is reported, and send the request again. Body keys are not query parameters. An unknown key in a request body is [validation-failed](#validation-failed). ### Invalid cursor The `cursor` could not be decoded, or it was issued for a different `sort`, filter set, search (`q`), `trashed` or `view`. Changing `per_page` does not invalidate a cursor. Start again from the first page. Always follow `links.next`, and never build or edit a cursor yourself. See [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/). ### Malformed request The request could not be read at all. You meet this type in two places: - The file upload, when you send the bytes to an `upload_url`: the body could not be read, or its length disagrees with `Content-Length`. - MCP: an `api_read` or `api_write` call whose path or query cannot form a request. Its `errors` use the codes `invalid`, `too_many` and `too_deep`. A JSON body that does not parse is not this type. It is read as an empty body: see [Write-body faults](#write-body-faults). ### Idempotency key required This `POST` refuses to run without an `Idempotency-Key` header. 3 operations require one, and each says so on its reference page. Add a key and send the request again. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). ## 401 Unauthorized Every `401` from `/api/v2` comes with the header `WWW-Authenticate: Bearer realm="plnnrly-api"`. ### Unauthenticated The request carries no usable token. The causes: - no `Authorization` header, or one that is not `Bearer ` - a malformed or unknown token - a revoked token - an expired token - a token from another organisation: a token works only on the address of the organisation it was created in Check the host and the token. If the token expired or was revoked, create a new one, as [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/) explains. An app that signs in with OAuth refreshes its access token instead, see [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). ### Token expired Declared in the contract, not sent today: an expired token answers [unauthenticated](#unauthenticated). Handle both types the same way. If this type is ever sent, it carries `expired_at`. ## 403 Forbidden Several types share this status, and each needs a different fix. Always read the `type`. ### Insufficient token scope The token is valid, but its scopes do not cover this operation. `required_ability` lists the abilities, any one of which would have worked, such as `["api:read:project_drive", "api:read:customer_drive"]`. Create a token that has one of them. The user may well hold the permission already. An app that signs in with OAuth asks for the scope when the user signs in. See [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/). ### Insufficient permission The token is broad enough, but the user's role lacks a plnnrly permission. `required_permission` names it, such as `projects::read`. An administrator has to change the role. No token can work around it. You also get this type when you write a field that needs an extra permission, or name another user in a user filter such as `filter[user_id]`. ### Forbidden by policy The token and the role are fine, but this record refuses this caller, for example because of who owns it. There is no extra member: the `detail` says why. Over MCP, a delete while **Allow destructive actions** is off is refused with this type. See [Permissions and safety](https://developers.plnnrly.com/en/docs/mcp/permissions-and-safety/). ## 404 Not Found ### Endpoint not found Nothing answers this request here. The causes, with the `detail` you see: | Cause | `detail` | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | No route matches the path. Check it against the reference. | No API endpoint matches this request. | | The host is wrong or old: a typo, a former address, or the old `plnnrly.com` address of an organisation that now uses its own domain. Use the address you sign in on. | No API is served on this host. | | An administrator has switched the REST API off. Every request answers this, whatever the token. | The REST API is not enabled for this organisation. | | The endpoint belongs to an area your organisation does not use, such as billing. | This endpoint is not available on this organisation. | Who can switch the REST API on is described in [For administrators](https://developers.plnnrly.com/en/docs/administration/#switches). ### Resource not found The endpoint exists, but the record does not, as far as you can tell. It does not exist, it is archived and `trashed` leaves it out, or you may not know that it exists. A record you may not see answers `404`, not `403`, so its existence does not leak. To read an archived record, add `trashed=with` where the operation accepts it. ## 405 Method Not Allowed ### Method not allowed The path exists, but not with this method. The `Allow` header lists the methods it accepts. There is no `PUT` anywhere in this API. Change a record with `PATCH`, and send only the fields you change. ## 406 Not Acceptable ### Not acceptable The `Accept` header rules out JSON. Leave the header out, or include `application/json` or `*/*`. A partial wildcard such as `application/*` is refused. ## 409 Conflict Some of these types are about the record, and the others are about the idempotency key. ### Duplicate resource Another record already uses this value. `conflict.field` and `conflict.value` name it. Choose another value. ### State conflict The request is valid, but the record's current state does not allow it. Examples: a workflow transition that is not allowed, starting a sprint that has already started, or deleting a project with logged time. `conflict.reason` is always present and says which. The other `conflict` keys depend on it: ```json { "type": "https://developers.plnnrly.com/api/v2/problems/state-conflict", "title": "State conflict", "status": 409, "detail": "The target status is not reachable from this work item's current status.", "instance": "/api/v2/work-items/812/actions/transition", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "conflict": { "reason": "illegal_transition", "from_workflow_status_id": 3, "to_workflow_status_id": 9 } } ``` [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/) lists the reasons each action raises, and [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/) covers what blocks a delete. The [problems schema](https://developers.plnnrly.com/en/docs/api/v2/schemas/problems/) lists every reason. ### Idempotency key reused The `Idempotency-Key` was already used for a different request: another body, or another path. A key belongs to the request it first succeeded with. Use a new key for a new request. ### Idempotency key in flight The first request with this key has not finished yet. The response carries `Retry-After: 1`. Wait, then send the same request with the same key. If the first request succeeded, you get its stored response. If it failed, your retry runs as a new request. ## 410 Gone ### Endpoint retired The endpoint has passed its sunset date and no longer answers. `sunset` is the date it stopped working. `replaced_by` names the route that replaces it, such as `api.v2.projects.show`, or is `null`. No endpoint is retired today. An endpoint is deprecated before it retires, and says so in its response headers: see [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/). ## 412 Precondition Failed ### Precondition failed Your `If-Match` no longer matches, because the record changed since you read it. The response carries the record's current `ETag` header. It also carries `current_version`, which is set on work items and `null` on everything else. Re-read the record, apply your change again, and retry with the new `ETag`. Do not resend your old change with the new tag: that overwrites exactly the change the check caught. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/#etag). ## 415 Unsupported Media Type ### Unsupported media type A request body arrived with a `Content-Type` other than `application/json`. A `charset` parameter is fine. There is no form encoding and no multipart on `/api/v2`. Files go through the upload flow, which sends the bytes in a separate request: see [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). ## 422 Unprocessable Content ### Validation failed The body was read, and some of it is wrong. `errors` has one entry per fault, each with a `pointer` to the field and a `code`. Common cases: - a field that is missing or in the wrong format, such as `required` or `date_format` - a body key the operation does not accept: `prohibited`, see [Write-body faults](#write-body-faults) - an id that does not exist, or that you cannot see: `exists` - a JSON body that does not parse: it is read as an empty body, so it usually fails as missing fields or as an empty `PATCH`, see [Write-body faults](#write-body-faults) - an `Idempotency-Key` longer than 255 characters: the entry has `parameter` set to `Idempotency-Key` and the code `max` ### Unprocessable relationship A record you refer to exists, but it cannot be used here. Examples: a sprint on another project, or one that is already completed; a folder in another drive; a work item linked to itself; an upload ticket created for another record. Carries `errors`. An id that does not exist, or that you cannot see, is [validation-failed](#validation-failed) with the code `exists` instead. The one exception is `after_work_item_id` on the move action, which reports it here. ## 429 Too Many Requests ### Rate limit exceeded A rate budget is used up: your token's, your organisation's, or the per-IP limit that applies before the token is checked. `rate_limit.limit` is the ceiling that ran out. `rate_limit.retry_after` and the `Retry-After` header give the seconds to wait. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). The file upload sends this type too, when more than three uploads are in flight for one token. That response carries `Retry-After: 5` and no `rate_limit` member. ## 500 Internal Server Error ### Internal error Something failed on our side. The `detail` is fixed and says nothing about the cause, but the `request_id` finds the whole request in our logs. See [Retrying](#retrying), then [Contacting support](#contacting-support). ## 503 Service Unavailable ### Tenant unavailable Your organisation cannot answer requests right now. It is still being set up, it is suspended or archived, or the service is down for maintenance. The response carries `Retry-After`, which is 30 seconds when the organisation itself is unavailable. Retry a few times. A suspended or archived organisation does not recover on its own, so stop retrying if it persists and contact an administrator. ## Problem types from the file upload When you send a file's bytes to its `upload_url`, most answers are problem documents with only `type`, `title`, `status` and `detail`, without `instance` or `request_id`. A `401`, an expired or changed `upload_url` and the per-minute upload limit answer with a plain JSON `message` instead, so switch on the status code there. The types below appear only on this URL. In each case the upload ticket stays open: see [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). ### Payload too large `413`. The file is larger than 10 MB, according to its `Content-Length` or to the bytes that arrived. A file this size cannot be uploaded. ### Request timeout `408`. No data arrived for 30 seconds, or the upload took longer than 120 seconds. Send the file again while the ticket is still valid. ## The `errors` array These types carry an `errors` array: `invalid-query-parameter`, `unsupported-parameter`, `validation-failed` and `unprocessable-relationship`. Over MCP, `malformed-request` carries one too. Each entry describes one fault and has exactly one of `pointer` or `parameter`. | Field | Meaning | | ----------- | -------------------------------------------------------------------------------- | | `pointer` | An RFC 6901 pointer into the request body, such as `/minutes` or `/label_ids/0`. | | `parameter` | The query parameter, brackets included, such as `filter[start_date][gte]`. | | `code` | The machine-readable fault. Switch on this. | | `detail` | An explanation of this one fault, for a person. | The `code` values: `unknown_parameter`, `unsupported_parameter`, `unknown_filter`, `unknown_operator`, `unknown_sort_key`, `unknown_include_path`, `depth_exceeded`, `count_exceeded`, `out_of_range`, `invalid_format`, `paired_filter_missing`, `mutually_exclusive`, `required`, `integer`, `numeric`, `string`, `boolean`, `array`, `min`, `max`, `between`, `size`, `distinct`, `date_format`, `decimal`, `email`, `url`, `uuid`, `regex`, `after`, `after_or_equal`, `before`, `exists`, `unique`, `required_with`, `prohibited`, `in` A validation rule that is not in this list reports its own name in snake case. New codes may be added, so treat an unknown code as unhandled and show its `detail`, rather than failing. ## Write-body faults Request bodies are closed: every key you send must be one the operation accepts. What happens when a body is wrong: | You send | You get | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A key the operation does not accept, such as `id` or `updated_at` | `422 validation-failed`, code `prohibited`, one entry per key at `/`. The `detail` lists the writable fields. | | A `PATCH` with no fields | `422 validation-failed`, code `required` at the pointer `/`. | | A body that is not valid JSON | It is read as an empty body. Most operations then answer the `422` for missing fields, or for an empty `PATCH`. An operation with no required field, such as `POST /checkins`, succeeds as if you had sent `{}`. Make sure your client sends valid JSON. | | A field that needs a permission you lack | Either `403 insufficient-permission`, with a `detail` such as `Writing "on_behalf_of_user_id" requires the times::update_others permission.`, or the field is dropped and the rest of the write is applied, with an `input_ignored` warning. Each field's description in the reference says which. | | A body with another `Content-Type` | `415 unsupported-media-type`. | ## Warnings are not errors A `2xx` response may carry `meta.warnings`. A warning never changes the status code. Read the warnings on every write: an `input_ignored` warning means a field you sent was not saved. See [Truncation](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/#truncation) for the `include_truncated` warning. ## Retrying Retry only what is safe to retry, and back off between attempts. | Response | Retry? | Wait | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | | `429` | Yes. | The seconds in `Retry-After`. | | `503` | Yes, a few times. A suspended or archived organisation does not recover on its own. | The seconds in `Retry-After`. | | `409` `idempotency-key-in-flight` | Yes, with the same key and body. | 1 second. | | `412` | Only after re-reading and re-applying your change. | No wait needed. | | `500`, or no answer at all | A `GET`: yes. A `POST` with an `Idempotency-Key`: yes, with the same key. A `POST` without a key: do not send it again until you have checked whether it went through, for example by reading the collection. A `PATCH` or `DELETE`: read the record first to see whether it went through. | Back off, and stop after a few attempts. | | Anything else | No. Fix the request first. | Does not apply. | ## Contacting support If an error does not make sense, contact [plnnrly support](https://www.plnnrly.com/en/support) and send: - the `request_id` from the response - your own `X-Request-Id`, if you sent one - the time of the request, in UTC - the operation: its method and path Never send your token. ## Related - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) - [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/) - [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/) --- # Limits at a glance Every numeric limit of the REST API and the MCP server in one place, with a link to the guide that explains each one. Every numeric limit of the REST API and the MCP server, in one place. Each row links to the guide that explains it. ## Paging and queries | Limit | Value | Explained in | | -------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `per_page` | 25 by default, at most 100. At most 25 when the request includes a to-many path. | [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/) | | `page` | 1 to 1000 | [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/) | | `sort` | At most 3 keys | [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/) | | Values in an `[in]` filter | At most 100 | [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/) | | `q` | At most 255 characters | [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/) | | `include` | At most 5 paths, each a single name: no nesting | [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/) | | Rows in a to-many include | At most 25, or 50 for `tags`, then the list is truncated | [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/#truncation) | ## Request headers | Limit | Value | Explained in | | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | `X-Request-Id` | 1 to 64 characters: letters, digits, `_` and `-` | [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/) | | `Idempotency-Key` | At most 255 characters | [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) | | Stored response for an `Idempotency-Key` | Replayed for 24 hours | [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) | | A request in progress holds its key for | At most 120 seconds | [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) | ## Rate limits | Limit | Value | Explained in | | ------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Per token | 600 read units and 120 write units per minute | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) | | Per organisation | 3000 read units and 600 write units per minute | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) | | Per IP address, before the token is checked | 600 requests per minute per IP address and host, 1200 per minute per IP address | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) | ## Tokens and OAuth | Limit | Value | Explained in | | ------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Personal access tokens | 10 live tokens per user | [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/) | | Token expiry | 90 days by default, at most 12 months | [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/) | | OAuth access token | Valid for 1 hour | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | | OAuth refresh token | Valid for 30 days | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | | App registration | 5 per minute per IP address, 30 per minute per organisation | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | | Token endpoint | 120 requests per minute per organisation and IP address | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | | OAuth discovery and revocation | 30 requests per minute per organisation and IP address | [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/) | ## File uploads | Limit | Value | Explained in | | ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | | File size | 10 MB (10,485,760 bytes) | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Open upload tickets | 10 per user | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Ticket validity | 30 minutes | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Uploads in flight | 3 at once per token | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Upload requests | 20 per minute per token | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Transfer time | Stops after 30 seconds without data, or 120 seconds in total | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | | Ticket retention | 24 hours after the file is registered; 7 days for an expired or revoked ticket | [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/) | ## MCP | Limit | Value | Explained in | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Requests | 60 per minute per user, counting every MCP request | [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/) | | Answer size | About 30,000 characters (the limit counts bytes of the JSON answer); anything cut is listed in `meta.mcp_truncated` | [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/) | | Path in `api_read` and `api_write` | At most 256 characters | [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/) | | Query in `api_read` and `api_write` | At most 60 parameters, nested at most 3 levels | [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/) | ## Data and caching | Limit | Value | Explained in | | -------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Event dates | `start` at most 5 years in the past, `end` at most 5 years ahead | [Create an event](https://developers.plnnrly.com/en/docs/api/v2/events/create/) | | Discovery response | Your client may cache it for 5 minutes: `Cache-Control: private, max-age=300` | [Discovery](https://developers.plnnrly.com/en/docs/api/v2/discovery/) | | Configuration reads, such as labels and work types | Your client may cache them for 1 minute: `Cache-Control: private, max-age=60` | [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) | ## Related - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) - [What the API cannot do](https://developers.plnnrly.com/en/docs/api/v2/guides/known-limitations/) - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) --- # Keeping a copy in sync plnnrly has no webhooks. Poll each collection from a high-water mark on updated_at, re-check cheaply, and catch deleted records. plnnrly does not push changes to you: there are no webhooks. To keep your own copy of the data up to date, you poll. This page shows a polling loop that does not miss changes and stays well inside your rate budget. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## The high-water mark Every collection accepts the filter `filter[updated_at][gte]` and the sort key `updated_at`. That is all a sync needs: 1. Read the collection sorted by `updated_at`, starting 60 seconds before your stored mark. 2. Follow `links.next` until it is `null`. 3. Insert or update each row in your copy by its `id`. 4. Store the highest `updated_at` you processed as your new mark. ```bash curl -G https:///api/v2/projects \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ --data-urlencode "filter[updated_at][gte]=2026-10-15T09:00:00Z" \ --data-urlencode "sort=updated_at,id" \ --data-urlencode "per_page=100" ``` `links.next` in each response is the full URL of the next page, with your filter and sort carried over. Request it as it is: ```bash curl "" \ -H "Authorization: Bearer " ``` For the very first run, leave the filter out and read the whole collection the same way. Things to know: - **Overlap is intended.** Start each run a little before your stored mark, for example 60 seconds before it. A change is stamped when it is made, not when it is saved for good. It can therefore appear after a read that has already passed its timestamp, and the overlap picks it up. Updating your copy by `id` makes the repeats harmless. - **Use the exact format.** A timestamp in a filter is UTC with a `Z` and no fractions of a second, such as `2026-10-15T09:00:00Z`. See [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/). - **A row can appear twice in one run.** When a row changes while you are paging, it moves to the end of the sort order and comes back later in the same run. - **Related lists are collections of their own.** `updated_at` belongs to the row itself. Poll lists that belong to a record, such as a work item's comments, separately. - **Some collections default to your own rows.** `/worklogs`, for example, returns only your own entries unless you name other users in `filter[user_id]`, which needs an extra permission. The operation page says so. See [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/). ## Cheap re-checks Small lists that you re-read in full, such as labels or workflow statuses, have a cheaper check. Send the page's weak `ETag` back as `If-None-Match`: ```bash curl -i https:///api/v2/labels \ -H "Authorization: Bearer " \ -H 'If-None-Match: W/"a1b2c3d4e5f6"' ``` An unchanged page answers `304 Not Modified` with no body, and costs 1 unit. A single record works the same way with its own `ETag`. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/#etag). ## Deletions Records disappear in two ways. [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/) lists which resources do what. **Archived records.** On collections whose rows carry `deleted_at`, a delete archives the record. Add `trashed=with` to your sync query. Archiving and restoring both move `updated_at`, so the change arrives through your high-water mark, with `deleted_at` set or cleared. **Permanent deletes leave no trace to poll for.** That includes a project deleted with `force=true`. Find them with a periodic id scan: read the whole collection by `id`, and remove from your copy every row the scan did not return. ```bash curl -G https:///api/v2/projects \ -H "Authorization: Bearer " \ --data-urlencode "sort=id" \ --data-urlencode "trashed=with" \ --data-urlencode "per_page=100" \ --data-urlencode "view=summary" ``` Keep `trashed=with` in the scan on collections that archive, or archived rows look deleted. A row you are no longer allowed to see also drops out of the scan. ## What it costs Take a sync of projects, customers and work items every 5 minutes, plus a nightly id scan. Costs follow [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/): | Step | Requests | Units | | ------------------------------------------------------------------------------------------------------------------------- | -------- | ----- | | A run where nothing changed: one first page per collection | 3 | 3 | | A run that finds 250 changed work items: three pages of up to 100 rows, plus one page each for projects and customers | 5 | 5 | | The same run with `include=labels` on the work items: a to-many path limits the page to 25 rows and adds 3 units per page | 12 | 42 | | A nightly id scan of 12,000 work items, at 100 per page | 120 | 120 | Every step stays far below the 600 read units a token may spend per minute. Keep includes out of the sync loop unless you need them: they cost more than they look. A separate read-only token for the sync gives it its own per-token budget. The organisation's budget is still shared with every other token. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/). ## Do not sync over MCP The MCP server is made for an AI assistant answering questions. It allows 60 requests per minute per user, lists default to 10 rows, and an answer is cut off at 30,000 characters: see [Tools](https://developers.plnnrly.com/en/docs/mcp/tools/). Sync with the REST API and a token instead. ## Related - [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/) - [Deleting and restoring](https://developers.plnnrly.com/en/docs/api/v2/guides/deleting-and-restoring/) - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) - [What the API cannot do](https://developers.plnnrly.com/en/docs/api/v2/guides/known-limitations/) --- # Headers and versioning The request headers you may send, the response headers you get back, and how the API changes within v2. This page lists the request headers the API reads, the response headers it sends, and what may change inside version 2. The reference repeats the header names on every operation page; their meaning is explained here, once. ## Request headers | Header | Required | Notes | | -------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Authorization: Bearer ` | Always | See [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/). | | `Accept` | No | Leave it out, or include `application/json` or `*/*`. Anything else, `application/*` included, is `406`. | | `Content-Type: application/json` | On a request with a body | Anything else is `415`. A `charset` parameter is fine. There is no form encoding and no multipart. | | `If-Match` | No, never required | Protects a write against overwriting a newer version. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). | | `If-None-Match` | No | Makes a read conditional: a match answers `304` with no body. | | `Idempotency-Key` | On a few `POST`s | Makes a `POST` safe to retry. At most 255 characters. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). | | `X-Request-Id` | No | Your own correlation id. See below. | 92 operations accept `If-Match` and 58 accept `Idempotency-Key`, of which 3 require it. Each operation page lists the headers it accepts. ### Your own request id Send `X-Request-Id` with 1 to 64 characters, using only the letters `A` to `Z` and `a` to `z`, the digits `0` to `9`, `_` and `-`. It is recorded with the request in our logs. Support can then find the request by your id as well as ours. Nothing echoes it back. The `X-Request-Id` response header always carries our own id, and your id never appears in a response body. A value outside the pattern is dropped silently, and the request runs as normal. ## Response headers ### On every response | Header | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Cache-Control` | `private, no-store`, unless the operation declares a cache lifetime of its own. Responses are never publicly cacheable. A few configuration reads declare `private, max-age=60`, and discovery declares `private, max-age=300`. | | `Vary` | The request headers the response depends on. Always includes `Authorization`, so a cache cannot serve one caller's rows to another. | | `RateLimit` | What is left in the tightest rate bucket. See [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/). | | `RateLimit-Policy` | That bucket's ceiling and window, as `;w=`. | | `X-Plnnrly-Api-Version` | The major version that answered. Always `2` on `/api/v2`. | | `X-Request-Id` | Our id for this request, a ULID. Send it when you contact support. | | `X-Content-Type-Options` | `nosniff`, problem documents included. | ### Only on some responses | Header | When | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | On every response with a body: `application/json`, or `application/problem+json` on an error. | | `ETag` | On a `200` or `201` with a record (strong) or a page of records (weak, `W/"…"`), on a `304`, and the record's current tag on a `412`. Never on a `204`, and not on other errors. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/#etag). | | `Location` | On a `201`: the absolute URL of the new record. The two file registrations, on attachments and on Drive files, do not send it. | | `Idempotency-Replayed: true` | On a `POST` whose response was replayed from an earlier request with the same `Idempotency-Key`. Absent otherwise. | ### On errors | Header | When | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Retry-After` | On a `429`, a `503`, and a `409` [idempotency-key-in-flight](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-in-flight). The seconds to wait. | | `Allow` | On a `405`: the methods the path accepts. | | `WWW-Authenticate` | On a `401`: `Bearer realm="plnnrly-api"`. | | `ETag` | On a `412`: the record's current tag. | ### Deprecated | Header | Sent until | Use instead | | ----------------------- | ---------- | ----------- | | `X-RateLimit-Limit` | 2027-09-01 | `RateLimit` | | `X-RateLimit-Remaining` | 2027-09-01 | `RateLimit` | | `X-RateLimit-Reset` | 2027-09-01 | `RateLimit` | `X-RateLimit-Reset` is a Unix timestamp, while the `reset` in `RateLimit` counts seconds from now. The reference strikes these headers through on every operation page. ## Versioning The version is in the path, and only in the path: `/api/v2`. There is no version header to send and no version parameter. `?version=` is an unknown query parameter, so it answers `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter). `X-Plnnrly-Api-Version` on a response only tells you which major version answered. It is useful in a log, not for choosing a version. ### What can change within v2 Changes within v2 are additive: a new field, a new optional parameter, a new endpoint, or a new value in a response enum. A change that would break a client does not ship in v2. Two requirements follow for your client: 1. **Accept fields you do not know.** Do not reject a response because it has an extra key. A field added next month must not break you. 2. **Accept enum values you do not know.** Enum fields and parameters marked as extensible may gain new values. The reference says so on the field, and the document carries 200 of these marks. Treat an unknown value as unhandled, for example by falling back to a default, never as an error. Requests are closed. An unknown body key is refused with `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed), code `prohibited`, and an unknown query parameter with `400` [invalid-query-parameter](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#invalid-query-parameter). Neither is ignored. A write that silently drops a field would be worse than one that is refused. [Generating a client](https://developers.plnnrly.com/en/docs/api/v2/guides/client-generation/) shows how to set up a generated client for these rules. ### Deprecation When something is deprecated, the API tells you in the response: | Deprecated | What you see | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | An endpoint | A `Deprecation` header with the date it was deprecated (`@` and a Unix timestamp), and a `Sunset` header with the date it stops. When it has a replacement, a `Link` header with `rel="deprecation"` points to its documentation. | | A field in a response | A `deprecated_field` entry in `meta.warnings`, with `sunset` (the date it stops) and `replacement` (what to use instead). | | Anything, in the reference | It is struck through, with its sunset date beside it. | After its sunset date, a retired endpoint answers `410` [endpoint-retired](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-retired), with `sunset` and `replaced_by`. The warning codes `deprecated_parameter` and `deprecated_endpoint` are declared in the contract but not sent yet. Today nothing is deprecated except the three `X-RateLimit-*` headers above. ### Tracking changes The whole contract is published as [/openapi.json](https://developers.plnnrly.com/openapi.json), in OpenAPI 3.1. The [changelog](https://developers.plnnrly.com/en/docs/changelog/) records what changed in each release. The `info.version` field in the document does not change with each release yet, so do not use it to detect a new release. To see exactly what changed, compare two copies of the document. ## Related - [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) - [Rate limits](https://developers.plnnrly.com/en/docs/api/v2/guides/rate-limits/) - [Generating a client](https://developers.plnnrly.com/en/docs/api/v2/guides/client-generation/) --- # What the API cannot do The things the REST API does not offer today, each with the page that explains it or the way around it. Before you design an integration, check this list. Each line names something the REST API does not do today, and links to the page that explains it or shows the way around it. ## Changes and sync - **No webhooks.** plnnrly does not push changes. Poll instead: [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/). - **Permanent deletes leave no trace.** Only archived records can be followed through `updated_at`. Find permanent deletes with an id scan: [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/#deletions). - **No sandbox organisation.** Every call works on real data. Start with a read-only token, then try writes with a narrower token on a test project: [Getting started](https://developers.plnnrly.com/en/docs/api/v2/guides/getting-started/). ## Writing - **No bulk endpoints.** No operation takes a list of records: each `POST` creates one record or runs one action, and each `PATCH` or `DELETE` changes one record. Send one request per record, each `POST` with its own `Idempotency-Key`: [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). - **No PUT.** Change a record with `PATCH`, sending only the fields you change: [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#method-not-allowed). - **Attachments and Drive files cannot be changed or deleted through the API** once they are registered. Their collections only have `GET` and `POST`: [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). - **Uploads need a personal access token.** An app that signs in with OAuth cannot upload, and neither can an MCP client: [Files: upload and download](https://developers.plnnrly.com/en/docs/api/v2/guides/uploads/). ## Reading - **Includes are documented on collection reads only.** For the related records of one record, read the related collection: [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/). - **No nested includes and no sparse fieldsets.** An include is one level deep, and you choose fields with `view=summary` or `view=detail`: [Views and includes](https://developers.plnnrly.com/en/docs/api/v2/guides/views-and-includes/). - **Users are read-only, and roles, permissions and teams are not exposed.** Manage people in plnnrly itself: [How the data fits together](https://developers.plnnrly.com/en/docs/api/v2/guides/data-model/). - **No currency field.** Amounts are decimal strings without a currency: [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/). - **Error messages are always in English**, whatever language your organisation uses: [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/). ## Signing in - **An app that signs in with OAuth cannot delete anything** through the REST API. No OAuth scope covers deletes, so deleting needs a personal access token: [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). - **No OAuth for web apps on their own domain.** OAuth sign-in accepts only loopback redirect addresses and a few known AI clients, so a web app cannot use its own `https` callback: [Sign in with OAuth](https://developers.plnnrly.com/en/docs/api/v2/guides/oauth-apps/). - **No calls from a browser on another site.** The API only allows browser requests from the plnnrly docs site. Call it from your own server instead: [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/). ## Versions and tools - **The document version is not a release marker yet.** `info.version` in `/openapi.json` does not change with each release. Follow the [changelog](https://developers.plnnrly.com/en/docs/changelog/) instead: [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/). - **No official SDK.** Generate a client from `/openapi.json`: [Generating a client](https://developers.plnnrly.com/en/docs/api/v2/guides/client-generation/). ## Related - [Limits at a glance](https://developers.plnnrly.com/en/docs/api/v2/guides/limits/) - [Keeping a copy in sync](https://developers.plnnrly.com/en/docs/api/v2/guides/syncing-data/) - [Generating a client](https://developers.plnnrly.com/en/docs/api/v2/guides/client-generation/) --- # Generating a client Generate a client or import the API into an API tool from /openapi.json, and avoid the traps that break generated clients. plnnrly publishes no SDK, but the whole API is described in one OpenAPI document. Generate a client from it, or import it into an API tool. This page lists the places where a generated client tends to break, and how to avoid them. In the examples, `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## The document [/openapi.json](https://developers.plnnrly.com/openapi.json) is an OpenAPI 3.1 document of about 5.2 MB, generated from the API's own code. It describes every operation in the reference. - **Server.** The server URL is `https://{tenant_host}/api/v2`. Set the variable `tenant_host` to ``. - **Authentication.** Every operation takes a bearer token: a personal access token or an OAuth access token. Each operation's `security` names the scopes that cover it, under the schemes `bearerToken` and `oauth2`. A `DELETE` lists `bearerToken` only, because no OAuth scope covers deletes. See [Authentication](https://developers.plnnrly.com/en/docs/api/v2/guides/authentication/). - **Operation ids.** Every operation has an id, such as `api.v2.work-items.links.store`, which a generator can use to name its methods. - **Nullable fields** use the OpenAPI 3.1 form, such as `"type": ["string", "null"]`. Use a generator that supports OpenAPI 3.1. ## Accept fields you do not know New fields can appear in any response within v2. Do not let your client reject a response because it has a key it does not know. Some response objects, such as `links` and `meta`, are declared with `additionalProperties: false`. That describes today's keys. It is not a promise that no key will be added, so turn off strict response validation in your client. See [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/). ## Accept enum values you do not know Enum fields and parameters marked `x-extensible-enum: true` may gain new values. The document carries 200 of these marks. This is an OpenAPI extension, so check whether your generator reads it. If it does not, make the generated enum types accept unknown values, or type those fields as plain strings. Treat an unknown value as unhandled, for example by falling back to a default, never as an error. ## Path parameters that take a key Some path parameters accept either the numeric id or a readable key, declared as `oneOf` an integer and a string with a pattern: | Parameter | Accepts | | ----------------- | ---------------------------------------------------------- | | `{work_item}` | The id, such as `812`, or the issue key, such as `ACM-12`. | | `{sales_invoice}` | The id, or the invoice number. | If your generator struggles with `oneOf` in a path, type the parameter as a string. Both forms are valid path segments. ## Filters - **Each filter is its own query parameter**, with the brackets in its name, such as `filter[due_date][gte]`. An `[in]` filter takes a comma-separated list. - **User filters accept a word as well as a number.** Filters such as `filter[user_id]`, `filter[assignee_id]` and `filter[project_manager_id]` accept the literal `me`, even where their schema says `integer`. The parameter's description says so. A strictly typed client needs a way to pass that string. See [Filtering and sorting](https://developers.plnnrly.com/en/docs/api/v2/guides/filtering-and-sorting/). ## Request bodies are closed Every request body is declared with `additionalProperties: false`, and the API enforces it: an unknown key is refused with `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed), code `prohibited`. So do not send back a record you have read. Its `id`, `created_at` and other read-only fields are not part of any request body. Build a `PATCH` body from only the fields you change. A single related record in a response, such as a project's `customer`, is `oneOf` a bare reference like `{ "id": 12 }` and a summary object with more fields. Both have `id`, so read the id from either. ## Decimals and date-times - **Decimals are strings**, such as `"1250.00"`, declared with `"format": "decimal"` and a pattern. `decimal` is not a standard OpenAPI format, so a generator that does not know it keeps the value as a string. Keep it as a string, or parse it into a decimal type. Never parse it into a floating-point number. - **Date-times are UTC, to the second**, such as `2026-10-15T09:00:00Z`. When you send one, leave out fractions of a second. In JavaScript, `toISOString()` adds milliseconds (`2026-10-15T09:00:00.000Z`), which the API refuses: `422` in a body, `400` in a filter. See [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/). ## Idempotency keys Send an `Idempotency-Key` on every `POST`. Every `POST` accepts one, and a few require it. Do not rely on the generated client to know which ones: the parameter description says so. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/). ## API tools Postman, Insomnia and Bruno can each import an OpenAPI document from a file or a URL. Import `https://developers.plnnrly.com/openapi.json`, then: 1. Set the base URL to `https:///api/v2`. 2. Set authentication to a bearer token, with your token as the value. Start with a read-only token while you explore. Writes from an API tool change real data. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/). ## Related - [Headers and versioning](https://developers.plnnrly.com/en/docs/api/v2/guides/headers-and-versioning/) - [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/) - [What the API cannot do](https://developers.plnnrly.com/en/docs/api/v2/guides/known-limitations/) --- # Recipes Worked examples that combine several requests into one task, with every step in curl, JavaScript and Python. Each recipe takes one task from a new token to a finished result. Every request in it is a real operation with its own page in the [reference](https://developers.plnnrly.com/en/docs/api/v2/). - [Log time from a script](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/log-time/): Book hours against a project from your own code, check the week, correct an entry and use timers, with retries that never book anything twice. - [Move work items through the workflow](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/work-item-workflow/): Read a work item by its key, find the statuses it can move to, change its status, place it on the board and leave a comment. --- # Log time from a script Book hours against a project from your own code, check the week, correct an entry and use timers, with retries that never book anything twice. This recipe books hours from your own code. You find the project, log the time, check the week, correct an entry and use a timer. Every request is a real operation with its own page in the [reference](https://developers.plnnrly.com/en/docs/api/v2/). Each step comes in three forms: curl, JavaScript and Python. The JavaScript runs on Node 20 or later as an ES module, so save it as a `.mjs` file. The Python uses [httpx](https://www.python-httpx.org/); install it with `pip install httpx`. Run the steps in order: later steps reuse values from earlier ones. Replace anything in angle brackets. `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## 1. Get a token In plnnrly, open **API access** in the user menu (top right, under your name) and choose **Create token**. If **API access** is not in your user menu, see [Who may create tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens). - **Scopes**: tick **Write**, which ticks **Read** for you. Also tick **Delete** if you will use timers (step 7) or delete an entry (step 6): discarding a timer and deleting a worklog are both deletes. - **Resources**: leave it on **All resources**. The token still cannot do more than your role allows. To narrow the token instead, pick every resource the script touches: | Resource | Why | | --------------------- | -------------------------------------------------------------- | | **Time entries** | Worklogs. | | **Projects** | Finding the project, and the `project_id` on every worklog. | | **Project workspace** | Only if you attach work items, or time them. | | **Kanban boards** | Only if you attach tasks. | | **Worklog overview** | Only for timers. Despite the name, it does not cover worklogs. | | **Users** | Only to log time for a colleague (step 8). | If **Time entries** is not in the **Resources** list, your role cannot narrow a token to it, so leave **Resources** on **All resources**. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/). Your role decides the rest: - **Finding projects, and every worklog**, needs the `projects::read` permission, because `project_id` is checked against it. Without it a worklog answers `422` with code `exists` on `/project_id`, even for a project id you know. - **Attaching or timing a work item** needs `project_management::view_backlog`. **Attaching a task** needs `kanban::manage_self`. - **Logging time** works on a project where you have a task, and on a work item that is booked on one of your tasks, as in plnnrly. A project, task or work item you can see but may not book on answers `403` [forbidden-by-policy](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#forbidden-by-policy). A task or work item from another project answers `422` on its own field. - **Reading worklogs and timers back** needs the `hours::read` permission, even for your own. ## 2. Set up your script Keep the address and the token out of your code, in two environment variables. Every snippet below uses them. ```bash [curl] export PLNNRLY_BASE_URL="https:///api/v2" export PLNNRLY_TOKEN="" ``` ```js [JavaScript] const BASE_URL = process.env.PLNNRLY_BASE_URL const TOKEN = process.env.PLNNRLY_TOKEN async function api(method, path, { query = {}, body, headers = {} } = {}) { const url = new URL(/^https?:/.test(path) ? path : `${BASE_URL}${path}`) for (const [name, value] of Object.entries(query)) { url.searchParams.set(name, String(value)) } const response = await fetch(url, { method, headers: { Authorization: `Bearer ${TOKEN}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }) const json = response.status === 204 ? null : await response.json() if (!response.ok) { throw new Error(`${response.status} ${json.type}: ${json.detail} (request ${json.request_id})`) } return { json, etag: response.headers.get('ETag') } } ``` ```python [Python] import os import httpx client = httpx.Client( base_url=os.environ["PLNNRLY_BASE_URL"], headers={"Authorization": f"Bearer {os.environ['PLNNRLY_TOKEN']}"}, timeout=30.0, ) def api(method, path, *, params=None, json=None, headers=None): response = client.request(method, path, params=params, json=json, headers=headers) if response.is_error: problem = response.json() raise RuntimeError( f"{response.status_code} {problem['type']}: {problem['detail']} " f"(request {problem['request_id']})" ) return response ``` Both helpers stop on any error response and print its `type` and `request_id`. Quote the `request_id` if you ask for help. ## 3. Find the project ```bash [curl] curl -G "$PLNNRLY_BASE_URL/projects" \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Accept: application/json" \ --data-urlencode "filter[status]=active" \ --data-urlencode "q=Website" ``` ```js [JavaScript] const { json: projects } = await api('GET', '/projects', { query: { 'filter[status]': 'active', q: 'Website' }, }) const project = projects.data[0] console.log(project.id, project.title) ``` ```python [Python] projects = api("GET", "/projects", params={"filter[status]": "active", "q": "Website"}).json() project = projects["data"][0] print(project["id"], project["title"]) ``` `q` searches project titles, and `filter[status]=active` keeps only active projects. Look the project up once and keep its `id`. ```jsonc { "data": [ { "id": 12, "title": "Website", "status": "active", "customer_id": 3 // 25 more fields: full_title, workflow_mode, start_date, … } ] // 2 more members: meta, links } ``` ## 4. Log the hours ```bash [curl] curl "$PLNNRLY_BASE_URL/worklogs" \ -X POST \ -i \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-timesheets-4711" \ -d '{ "project_id": 12, "work_item_id": 812, "date": "2026-10-14", "minutes": 90, "description": "Design review with the customer" }' ``` ```js [JavaScript] const entry = { id: 4711, date: '2026-10-14', minutes: 90, description: 'Design review with the customer' } const { json: logged, etag } = await api('POST', '/worklogs', { headers: { 'Idempotency-Key': `acme-timesheets-${entry.id}` }, body: { project_id: project.id, work_item_id: 812, date: entry.date, minutes: entry.minutes, description: entry.description, }, }) for (const warning of logged.meta?.warnings ?? []) { console.warn(`${warning.code} ${warning.pointer}: ${warning.detail}`) } ``` ```python [Python] entry = {"id": 4711, "date": "2026-10-14", "minutes": 90, "description": "Design review with the customer"} response = api( "POST", "/worklogs", headers={"Idempotency-Key": f"acme-timesheets-{entry['id']}"}, json={ "project_id": project["id"], "work_item_id": 812, "date": entry["date"], "minutes": entry["minutes"], "description": entry["description"], }, ) logged = response.json() etag = response.headers["ETag"] for warning in logged.get("meta", {}).get("warnings", []): print(f"{warning['code']} {warning.get('pointer')}: {warning['detail']}") ``` A worklog needs four fields and one header: - `project_id`: the project from step 3. - `date`: the day the work happened, as `YYYY-MM-DD`. - `minutes`: whole minutes, at most one day per entry. - `description`: what you worked on. - `Idempotency-Key`: required on this operation. Without it the answer is `400` [idempotency-key-required](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-required). Build it from something that identifies the entry in your own system, such as the row `4711` in your timesheet, so a retry reuses it (step 9). Optional fields: - `work_item_id` attaches the hours to a work item, and `task_id` to a task. Leave both out to book on the project only. - `is_not_billable` is best left out: billability follows the task. Overriding it needs a `billable_change_reason` and sends the entry to a reviewer. The hours are booked for you, the owner of the token. The answer is `201` with the new worklog, and its address in `Location`: ```http HTTP/1.1 201 Created Location: https://acme.plnnrly.com/api/v2/worklogs/5512 ETag: "worklogs-5512-t1791991800-9c1e" ``` ```jsonc { "data": { "id": 5512, "date": "2026-10-14", "minutes": 90, "description": "Design review with the customer", "project_id": 12, "project": { "id": 12, "title": "Website", "customer_id": 3 }, "work_item_id": 812, "work_item": { "id": 812, "issue_key": "ACM-12", "title": "Redesign the pricing page" }, "user_id": 7, "is_not_billable": false, "is_overtime": false // 14 more fields: budget_id, budget, task_id, task, … } } ``` Keep the `ETag` for step 6. **Check for warnings.** A field you may not set can be ignored instead of refused. Send `"is_overtime": true` without the `times::write_overtime` permission, and the hours are booked as regular time. The answer is still `201`, with `is_overtime` set to `false` and this warning: ```jsonc { "data": { "id": 5513, "is_overtime": false // 23 more fields, as in the response above }, "meta": { "warnings": [ { "code": "input_ignored", "detail": "The field \"is_overtime\" was ignored: writing it requires the times::write_overtime permission. The rest of the write was applied.", "pointer": "/is_overtime" } ] } } ``` A project you cannot see answers `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) with code `exists` on `/project_id`. ## 5. Check the week ```bash [curl] curl -G "$PLNNRLY_BASE_URL/worklogs" \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ --data-urlencode "filter[date][gte]=2026-10-12" \ --data-urlencode "filter[date][lte]=2026-10-18" \ --data-urlencode "per_page=100" ``` ```js [JavaScript] let weekMinutes = 0 let page = (await api('GET', '/worklogs', { query: { 'filter[date][gte]': '2026-10-12', 'filter[date][lte]': '2026-10-18', per_page: 100 }, })).json while (true) { weekMinutes += page.data.reduce((sum, row) => sum + row.minutes, 0) if (page.links.next === null) { break } page = (await api('GET', page.links.next)).json } console.log(`${(weekMinutes / 60).toFixed(1)} hours this week`) ``` ```python [Python] week_minutes = 0 page = api( "GET", "/worklogs", params={"filter[date][gte]": "2026-10-12", "filter[date][lte]": "2026-10-18", "per_page": 100}, ).json() while True: week_minutes += sum(row["minutes"] for row in page["data"]) if page["links"]["next"] is None: break page = api("GET", page["links"]["next"]).json() print(f"{week_minutes / 60:.1f} hours this week") ``` The list holds your own entries, because `filter[user_id]` defaults to you. It is sorted by day, newest first. Follow `links.next` until it is `null`: it carries your filters, so you never build the next request yourself. See [Pagination](https://developers.plnnrly.com/en/docs/api/v2/guides/pagination/). Without the `hours::read` permission, this answers `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission), even though the entries are yours. ## 6. Correct an entry ```bash [curl] curl "$PLNNRLY_BASE_URL/worklogs/5512" \ -X PATCH \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H 'If-Match: "worklogs-5512-t1791991800-9c1e"' \ -d '{ "minutes": 105 }' ``` ```js [JavaScript] const { json: corrected } = await api('PATCH', `/worklogs/${logged.data.id}`, { headers: { 'If-Match': etag }, body: { minutes: 105 }, }) console.log(corrected.data.minutes) ``` ```python [Python] corrected = api( "PATCH", f"/worklogs/{logged['data']['id']}", headers={"If-Match": etag}, json={"minutes": 105}, ).json() print(corrected["data"]["minutes"]) ``` Send only the fields you change. With `If-Match`, the change is refused with `412` [precondition-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#precondition-failed) if the entry changed after the response you took the `ETag` from. Then read it again and decide. Worklogs have no version number, so this check compares the time of the last change, to the second. Treat it as a safety net, not a lock. See [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/). Two things cannot be corrected: - **An invoiced entry.** It answers `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict) with `conflict.reason` `locked_by_invoice`, and stays as it was billed. - **Who the hours belong to.** To give an entry to someone else, delete it and log it again for them (step 8). ## 7. Use a timer instead A timer is a stopwatch on a work item or a planning event: `timeable_type` is `work_item` or `event`. It books nothing by itself. When the work is done, you log the minutes as a worklog and then discard the timer. ```bash [curl] # Start the timer curl "$PLNNRLY_BASE_URL/timers" \ -X POST \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "timeable_type": "work_item", "timeable_id": 812 }' # Later: log the minutes, then discard the timer curl "$PLNNRLY_BASE_URL/worklogs" \ -X POST \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-timer-301" \ -d '{ "project_id": 12, "work_item_id": 812, "date": "2026-10-14", "minutes": 47, "description": "Fixed the checkout bug" }' curl "$PLNNRLY_BASE_URL/timers/301" \ -X DELETE \ -H "Authorization: Bearer $PLNNRLY_TOKEN" ``` ```js [JavaScript] const { json: started } = await api('POST', '/timers', { body: { timeable_type: 'work_item', timeable_id: 812 }, }) const timer = started.data // Later: log the minutes, then discard the timer const elapsed = Math.max(1, Math.round((Date.now() - Date.parse(timer.started_at)) / 60_000)) await api('POST', '/worklogs', { headers: { 'Idempotency-Key': `acme-timer-${timer.id}` }, body: { project_id: project.id, work_item_id: timer.timeable_id, date: new Date().toLocaleDateString('en-CA'), minutes: elapsed, description: 'Fixed the checkout bug', }, }) await api('DELETE', `/timers/${timer.id}`) ``` ```python [Python] from datetime import date, datetime, timezone timer = api("POST", "/timers", json={"timeable_type": "work_item", "timeable_id": 812}).json()["data"] # Later: log the minutes, then discard the timer started_at = datetime.fromisoformat(timer["started_at"].replace("Z", "+00:00")) elapsed = max(1, round((datetime.now(timezone.utc) - started_at).total_seconds() / 60)) api( "POST", "/worklogs", headers={"Idempotency-Key": f"acme-timer-{timer['id']}"}, json={ "project_id": project["id"], "work_item_id": timer["timeable_id"], "date": date.today().isoformat(), "minutes": elapsed, "description": "Fixed the checkout bug", }, ) api("DELETE", f"/timers/{timer['id']}") ``` - **Starting**: `started_at` defaults to now, in UTC. You can run one timer per work item, so starting a second one answers `422` with code `unique`, and a retried start cannot run two. - **Stopping**: there is no stop action. Work out the minutes from `started_at`, log them, and discard the timer. - **Whose timer**: a timer always belongs to the person who starts it. > **Warning:** > Log first, then discard. These are two separate requests. If you discard first, the minutes are gone. If your script stops in between, the timer keeps running, so check `GET /timers` for leftovers. ## 8. Log time for a colleague ```bash [curl] curl "$PLNNRLY_BASE_URL/worklogs" \ -X POST \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-timesheets-4712" \ -d '{ "project_id": 12, "on_behalf_of_user_id": 34, "date": "2026-10-14", "minutes": 240, "description": "On-site migration support" }' ``` ```js [JavaScript] await api('POST', '/worklogs', { headers: { 'Idempotency-Key': 'acme-timesheets-4712' }, body: { project_id: project.id, on_behalf_of_user_id: 34, date: '2026-10-14', minutes: 240, description: 'On-site migration support', }, }) ``` ```python [Python] api( "POST", "/worklogs", headers={"Idempotency-Key": "acme-timesheets-4712"}, json={ "project_id": project["id"], "on_behalf_of_user_id": 34, "date": "2026-10-14", "minutes": 240, "description": "On-site migration support", }, ) ``` `on_behalf_of_user_id` books the hours for someone else. Find their id with [GET /users](https://developers.plnnrly.com/en/docs/api/v2/users/list/). - It needs the `times::update_others` permission. Without it the answer is `403` [insufficient-permission](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#insufficient-permission), and `required_permission` names what is missing. - Naming a colleague also needs the `users::read` permission, and a narrowed token needs **Users**. Without them the answer is `422` [validation-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#validation-failed) with code `exists` on `/on_behalf_of_user_id`, before `times::update_others` is checked. `GET /users` needs the same two, and answers `403` without them. - The other rules are checked against your colleague. They need a task on the project, and `is_overtime` only counts if both of you may log overtime. ## 9. Retry without booking twice Requests fail: a timeout, a dropped connection, a `503`. Retry a `POST /worklogs` with the **same** `Idempotency-Key` and the same body. If the first attempt was booked, plnnrly answers with the stored response and the header `Idempotency-Replayed: true`, and books nothing new. Keys are kept for 24 hours. - **Same key, different body**: `409` [idempotency-key-reused](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-reused). A new entry needs a new key. - **First attempt still running**: `409` [idempotency-key-in-flight](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#idempotency-key-in-flight). Wait as long as `Retry-After` says, then retry. - **First attempt failed**: a failed request is not stored. Fix the body and send it again with the same key. See [Idempotency](https://developers.plnnrly.com/en/docs/api/v2/guides/idempotency/) for the full rules, and [Errors and retries](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/) for which failures are worth a retry. ## Related - [Data formats](https://developers.plnnrly.com/en/docs/api/v2/guides/data-formats/): dates, times and minutes on the wire. - [Scopes and permissions](https://developers.plnnrly.com/en/docs/api/v2/guides/scopes/): what your token and your role allow. - [Move work items through the workflow](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/work-item-workflow/): the other recipe. --- # Move work items through the workflow Read a work item by its key, find the statuses it can move to, change its status, place it on the board and leave a comment. This recipe moves a work item through its workflow from your own code. You read the item, find the statuses it may move to, change its status, place it on the board and leave a comment. The examples use the work item `ACM-12`. Every request is a real operation with its own page in the [reference](https://developers.plnnrly.com/en/docs/api/v2/). Each step comes in three forms: curl, JavaScript and Python. The JavaScript runs on Node 20 or later as an ES module, so save it as a `.mjs` file. The Python uses [httpx](https://www.python-httpx.org/); install it with `pip install httpx`. Run the steps in order: later steps reuse values from earlier ones. Replace anything in angle brackets. `` is the address you sign in to plnnrly on, such as `acme.plnnrly.com`, or your organisation's own domain. ## 1. Get a token In plnnrly, open **API access** in the user menu (top right, under your name) and choose **Create token**. If **API access** is not in your user menu, see [Who may create tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#who-may-create-tokens). - **Scopes**: tick **Write**, which ticks **Read** for you. - **Resources**: leave it on **All resources**, or pick **Project workspace** and **Workflows**. **Project workspace** covers work items and their comments. **Workflows** covers the statuses and the moves between them. See [API tokens](https://developers.plnnrly.com/en/docs/api/v2/guides/tokens/#resources). Your role decides the rest: - **Reading and changing work items** needs the `project_management::view_backlog` and `project_management::manage_work_items` permissions. - **Looking up statuses, and changing to one**, needs `workflows::read`. The workflow lists in step 4 are only there while your organisation has the Sprints feature. Without it they answer `404` [endpoint-not-found](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#endpoint-not-found). ## 2. Set up your script Keep the address and the token out of your code, in two environment variables. Every snippet below uses them. ```bash [curl] export PLNNRLY_BASE_URL="https:///api/v2" export PLNNRLY_TOKEN="" ``` ```js [JavaScript] const BASE_URL = process.env.PLNNRLY_BASE_URL const TOKEN = process.env.PLNNRLY_TOKEN async function api(method, path, { query = {}, body, headers = {} } = {}) { const url = new URL(/^https?:/.test(path) ? path : `${BASE_URL}${path}`) for (const [name, value] of Object.entries(query)) { url.searchParams.set(name, String(value)) } const response = await fetch(url, { method, headers: { Authorization: `Bearer ${TOKEN}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), }) const json = response.status === 204 ? null : await response.json() if (!response.ok) { throw new Error(`${response.status} ${json.type}: ${json.detail} (request ${json.request_id})`) } return { json, etag: response.headers.get('ETag') } } ``` ```python [Python] import os import httpx client = httpx.Client( base_url=os.environ["PLNNRLY_BASE_URL"], headers={"Authorization": f"Bearer {os.environ['PLNNRLY_TOKEN']}"}, timeout=30.0, ) def api(method, path, *, params=None, json=None, headers=None): response = client.request(method, path, params=params, json=json, headers=headers) if response.is_error: problem = response.json() raise RuntimeError( f"{response.status_code} {problem['type']}: {problem['detail']} " f"(request {problem['request_id']})" ) return response ``` Both helpers stop on any error response and print its `type` and `request_id`. Quote the `request_id` if you ask for help. ## 3. Read the work item A work item path takes its issue key, such as `ACM-12`, or its numeric id, such as `812`. Both name the same item. ```bash [curl] curl "$PLNNRLY_BASE_URL/work-items/ACM-12?meta=actions" \ -i \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Accept: application/json" ``` ```js [JavaScript] const { json: item, etag } = await api('GET', '/work-items/ACM-12', { query: { meta: 'actions' }, }) console.log(item.data.workflow_status.name, item.meta?.actions, etag) ``` ```python [Python] response = api("GET", "/work-items/ACM-12", params={"meta": "actions"}) item = response.json() etag = response.headers["ETag"] print(item["data"]["workflow_status"]["name"], item.get("meta", {}).get("actions"), etag) ``` ```http HTTP/1.1 200 OK ETag: "work-items-812-v7-4be2" ``` ```jsonc { "data": { "id": 812, "issue_key": "ACM-12", "title": "Redesign the pricing page", "version": 7, "project_id": 12, "sprint_id": null, "workflow_status_id": 27, "workflow_status": { "id": 27, "name": "In progress", "color": "#2563eb", "is_resolution": false } // 32 more fields: description, priority, due_date, … }, "meta": { "actions": ["move", "transition"] } } ``` - `workflow_status_id` is where the item is now. Step 4 starts from it. - `meta.actions` lists what you may do with this item right now, because you asked with `meta=actions`. If `transition` is missing, your token or your role cannot change its status. - Keep the `ETag` for step 5. It changes with `version`, which goes up with every change to the item. ## 4. Find where it can move A workflow only allows the moves it defines. Ask for the moves out of the current status, with the status each one leads to: ```bash [curl] curl -G "$PLNNRLY_BASE_URL/workflow-transitions" \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ --data-urlencode "filter[from_status_id]=27" \ --data-urlencode "include=to_status" ``` ```js [JavaScript] const { json: transitions } = await api('GET', '/workflow-transitions', { query: { 'filter[from_status_id]': item.data.workflow_status_id, include: 'to_status' }, }) const target = transitions.data.find((transition) => transition.to_status.name === 'In review') if (target === undefined) { throw new Error('ACM-12 cannot move to In review from its current status') } ``` ```python [Python] transitions = api( "GET", "/workflow-transitions", params={"filter[from_status_id]": item["data"]["workflow_status_id"], "include": "to_status"}, ).json() target = next((t for t in transitions["data"] if t["to_status"]["name"] == "In review"), None) if target is None: raise RuntimeError("ACM-12 cannot move to In review from its current status") ``` ```jsonc { "data": [ { "id": 54, "name": "Ready for review", "from_status_id": 27, "to_status_id": 31, "to_status": { "id": 31, "name": "In review", "color": "#7c3aed", "is_resolution": false } // 6 more fields: workflow_id, created_at, updated_at, workflow, from_status, links } ] // 2 more members: meta, links } ``` Each row is one move the workflow allows. `name` is the label on the button in plnnrly, and `to_status` is where the move leads. To list every status of a workflow instead, use [GET /workflow-statuses](https://developers.plnnrly.com/en/docs/api/v2/workflow-statuses/list/) with `filter[workflow_id]`. ## 5. Change the status ```bash [curl] curl "$PLNNRLY_BASE_URL/work-items/ACM-12/actions/transition" \ -X POST \ -i \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transition-ACM-12-31-v7" \ -H 'If-Match: "work-items-812-v7-4be2"' \ -d '{ "workflow_status_id": 31 }' ``` ```js [JavaScript] const transitioned = await api('POST', '/work-items/ACM-12/actions/transition', { headers: { 'Idempotency-Key': `transition-ACM-12-${target.to_status_id}-v${item.data.version}`, 'If-Match': etag, }, body: { workflow_status_id: target.to_status_id }, }) console.log(transitioned.json.data.workflow_status.name, transitioned.etag) ``` ```python [Python] response = api( "POST", "/work-items/ACM-12/actions/transition", headers={ "Idempotency-Key": f"transition-ACM-12-{target['to_status_id']}-v{item['data']['version']}", "If-Match": etag, }, json={"workflow_status_id": target["to_status_id"]}, ) transitioned = response.json() transitioned_etag = response.headers["ETag"] print(transitioned["data"]["workflow_status"]["name"], transitioned_etag) ``` The answer is `200` with the whole work item in its new status, its `version` one higher, and a new `ETag`. Keep that `ETag` for step 6. - `If-Match` keeps you from overwriting a change you have not seen. If someone changed the item after your read, the answer is `412` [precondition-failed](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#precondition-failed). It carries the current `ETag` and `current_version`: read the item again, check it still needs the change, and retry. - The `Idempotency-Key` makes a retry safe. A retry with a stale `If-Match` would fail with `412`; with the same key it gets the stored answer instead. The key above names the item, the target and the version, so it stays the same on a retry and changes for the next real move. - **Moving to the status the item already has** succeeds and changes nothing. A move the workflow does not define answers `409` [state-conflict](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#state-conflict), naming both ends: ```json { "type": "https://developers.plnnrly.com/api/v2/problems/state-conflict", "title": "State conflict", "status": 409, "detail": "The target status is not reachable from this work item's current status.", "instance": "/api/v2/work-items/ACM-12/actions/transition", "request_id": "01M4XGKWHGD5NFRBNNG8KE8MRX", "conflict": { "reason": "illegal_transition", "from_workflow_status_id": 27, "to_workflow_status_id": 33 } } ``` ## 6. Place it on the board ```bash [curl] curl "$PLNNRLY_BASE_URL/work-items/ACM-12/actions/move" \ -X POST \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: move-ACM-12-board-top-v8" \ -H 'If-Match: "work-items-812-v8-4be2"' \ -d '{ "list": "board", "after_work_item_id": null }' ``` ```js [JavaScript] const moved = await api('POST', '/work-items/ACM-12/actions/move', { headers: { 'Idempotency-Key': `move-ACM-12-board-top-v${transitioned.json.data.version}`, 'If-Match': transitioned.etag, }, body: { list: 'board', after_work_item_id: null }, }) console.log(moved.json.data.column_order) ``` ```python [Python] moved = api( "POST", "/work-items/ACM-12/actions/move", headers={ "Idempotency-Key": f"move-ACM-12-board-top-v{transitioned['data']['version']}", "If-Match": transitioned_etag, }, json={"list": "board", "after_work_item_id": None}, ).json() print(moved["data"]["column_order"]) ``` This puts `ACM-12` at the top of its column, the way dragging it in plnnrly does. The [operation page](https://developers.plnnrly.com/en/docs/api/v2/work-items/move/) has every field; the ones you need most: - `list` says which order to change: `board` is the item's column on the board, and `backlog` is its place in its sprint, or in the backlog when it is in no sprint. - `after_work_item_id` names the item to land directly below, by key or by id, such as `"ACM-9"`. `null` puts the item at the top. Leave it out to keep the item's place in its current list, or to put it at the end of a new one. - `workflow_status_id` and `sprint_id` change the status and the sprint in the same request. The status is checked as in step 5, and `"sprint_id": null` moves the item to the backlog. On a project that works in sprints, the board shows the running sprint only. Moving an item onto the board that is not in that sprint answers `422` [unprocessable-relationship](https://developers.plnnrly.com/en/docs/api/v2/guides/errors/#unprocessable-relationship). A move to where the item already is changes nothing, so a retry is safe. ## 7. Leave a comment ```bash [curl] curl "$PLNNRLY_BASE_URL/work-items/ACM-12/comments" \ -X POST \ -H "Authorization: Bearer $PLNNRLY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: comment-ACM-12-review-v8" \ -d '{ "body": "

Deployed to staging, ready for review.

" }' ``` ```js [JavaScript] await api('POST', '/work-items/ACM-12/comments', { headers: { 'Idempotency-Key': `comment-ACM-12-review-v${transitioned.json.data.version}` }, body: { body: '

Deployed to staging, ready for review.

' }, }) ``` ```python [Python] api( "POST", "/work-items/ACM-12/comments", headers={"Idempotency-Key": f"comment-ACM-12-review-v{transitioned['data']['version']}"}, json={"body": "

Deployed to staging, ready for review.

"}, ) ``` `body` is rich text, written as HTML. The comment is posted as you, the same way as in plnnrly. You cannot create a new mention through the API. ## 8. The same over MCP An AI assistant connected to the [MCP server](https://developers.plnnrly.com/en/docs/mcp/) can do all of this from one sentence: > Move ACM-12 to In review, put it at the top of its board column and comment: Deployed to staging, ready for review. The assistant uses the same operations as this recipe. It acts as you, so it can do only what your role allows. More prompts are in [What you can ask](https://developers.plnnrly.com/en/docs/mcp/examples/). ## Related - [Actions and state changes](https://developers.plnnrly.com/en/docs/api/v2/guides/actions/): the rules every action shares. - [Concurrency and ETags](https://developers.plnnrly.com/en/docs/api/v2/guides/concurrency-and-etags/): `If-Match` in depth. - [Log time from a script](https://developers.plnnrly.com/en/docs/api/v2/guides/recipes/log-time/): the other recipe. --- # Operations --- # GET /api/v2/ List the resources this API exposes - MCP id: discovery - Scope: api:read - Permission: none, the record's own rules still apply - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=300 Returns one entry per resource available to your organisation, with its collection URL, its templated item URL, its permission prefix, the OpenAPI schemas that describe it and the operations it registers. Resources belonging to a feature your organisation does not have are omitted. Not every entry is an endpoint you can call: the list does not depend on your token's scope or your permissions, so a listed operation can still answer `403`, and a `null` `path` or `item_path` means the resource has no such URL. It needs a valid token but no permission. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - name (string, required): Human-readable name of the resource collection. - path (string or null, required): Absolute URL of the collection endpoint, or `null` where this resource registers no flat collection and is reached only through a parent or by id. - item_path (string or null, required): Templated absolute URL of the single-resource endpoint, with the path parameter left as a `{placeholder}`. Null where the resource has no flat route at all. - permission_prefix (string, required): The permission prefix this resource is gated on. It is the value after the class in an `api:{class}:{prefix}` token ability. - summary_schema (string, required): JSON pointer to the OpenAPI component describing this resource in the `summary` view, which is what a collection returns by default. - detail_schema (string, required): JSON pointer to the OpenAPI component describing the `detail` view, which is what a single-resource read and every write return. - operations (array of string, required): The operations this resource registers, as `index`, `store`, `show`, `update`, `destroy` and `actions.{verb}`. A nested operation keeps its full identifier, so `work-items.comments.index` appears verbatim. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/absences List absences - MCP id: absences.index - Scope: api:read:absences - Permission: absences::manage - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1. - Cache-Control: private, no-store Returns absences (the periods employees were away from work), newest first by `start`. An absence with a `null` `end` is still running. There is no `year` parameter and no `ongoing` parameter: filter a year as `filter[start][gte]=2026-01-01T00:00:00Z&filter[start][lte]=2026-12-31T23:59:59Z`, and the running absences as `filter[end][null]=true`. There is no `q` parameter either: an absence has no text of its own. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[end] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the absence ends. `null` selects the absences that are still running. Operators: = gte lte null. - filter[manager_id] (integer, optional): Manager who filed or approved the absence. `null` selects absences with no manager recorded. Operators: = in null. - filter[start] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the absence begins. Operators: = gte lte. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose absence this is. Accepts the literal `me`. Naming another user requires the `absences::manage` permission. Operators: = in. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - user (Ref or UserSummary, required): The employee the absence is for. Renders as an `{"id": N}` reference unless expanded, and stays a reference when the employee has since been deleted. - manager_id (integer or null, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - manager (Ref or UserSummary or null, required): The manager who filed or approved the absence; absent when none was recorded. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (AbsenceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/absences' \ --data-urlencode 'filter[end][null]=true' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/absences Book an absence - MCP id: absences.store - Scope: api:write:absences - Permission: absences::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Books an absence for an employee and returns it with a `Location` header. `start` and `end` are stored exactly as sent: they are RFC 3339 UTC instants and no timezone conversion is applied, unlike the form in plnnrly, which collects local wall-clock time. Omit `end` for an absence that is still running; it then reaches through now everywhere it is read, so close it when the employee returns. Naming a `user_id` or `manager_id` additionally requires read access to users (`users::read`) and a token covering that prefix, because the reference is checked against what the caller may see. Nothing prevents two overlapping absences for one employee. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - user_id (integer → User.id, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - manager_id (integer → User.id, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. Must be on or after start. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - user (Ref or UserSummary, required): The employee the absence is for. Renders as an `{"id": N}` reference unless expanded, and stays a reference when the employee has since been deleted. - manager_id (integer or null, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - manager (Ref or UserSummary or null, required): The manager who filed or approved the absence; absent when none was recorded. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (AbsenceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/absences' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "user_id": 1, "manager_id": 1, "start": "2026-03-02T08:00:00Z", "end": "2026-03-06T17:00:00Z" }' ``` --- # GET /api/v2/absences/{absence} Read an absence - MCP id: absences.show - Scope: api:read:absences - Permission: absences::manage - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one absence. The summary and detail views are identical on this resource: every field is small enough for a list row. `user` and `manager` render as `{"id": N}` references; `?include=user,manager` expands them, and that expansion additionally requires `users::read` and a token scope covering the users prefix. An absence whose employee has since been deleted keeps the reference form rather than expanding. ## Parameters - absence (integer, path): The Absence's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - user (Ref or UserSummary, required): The employee the absence is for. Renders as an `{"id": N}` reference unless expanded, and stays a reference when the employee has since been deleted. - manager_id (integer or null, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - manager (Ref or UserSummary or null, required): The manager who filed or approved the absence; absent when none was recorded. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (AbsenceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/absences/1' \ --data-urlencode 'include=user,manager' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/absences/{absence} Update an absence - MCP id: absences.update - Scope: api:write:absences - Permission: absences::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates an absence: absent keys are untouched, an empty body is rejected. Send `end` to close a running absence, or `end: null` to reopen one; send `manager_id: null` to detach the manager. `user_id` may be moved to another employee. `start` and `end` are stored exactly as sent, with no timezone conversion. Naming a `user_id` or `manager_id` additionally requires read access to users (`users::read`) and a token covering that prefix. Note: `end` is only checked against `start` when both are sent in the same request, so sending `end` alone can leave it before the stored `start`; an absence in that state counts as zero minutes everywhere it is read. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - absence (integer, path): The Absence's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - user_id (integer → User.id, optional): The employee this absence is for. Writing it requires the `absences::manage` permission. - manager_id (integer or null → User.id, optional): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. Must be on or after start. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - user (Ref or UserSummary, required): The employee the absence is for. Renders as an `{"id": N}` reference unless expanded, and stays a reference when the employee has since been deleted. - manager_id (integer or null, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - manager (Ref or UserSummary or null, required): The manager who filed or approved the absence; absent when none was recorded. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (AbsenceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/absences/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "end": "2026-03-06T17:00:00Z" }' ``` --- # DELETE /api/v2/absences/{absence} Delete an absence - MCP id: absences.destroy - Scope: api:destroy:absences - Permission: absences::manage - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes an absence. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Nothing blocks the delete, and the capacity the absence was consuming in planning and reporting comes straight back. The deletion itself is recorded in the absence audit log. ## Parameters - absence (integer, path): The Absence's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/absences/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/budget-groups List budget groups - MCP id: budget-groups.index - Scope: api:read:budget_groups - Permission: budget_groups::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's budget groups, sorted by name A→Z, each with the number of budgets filed under it. Pass `view=detail` for the full payload at three read units instead of one; the two views are identical on this resource. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budgets_count (integer ≥ 0, required): Number of budgets filed under this group. - links (BudgetGroupLinks, required) - self (url, required): The canonical URI of this record. - budgets (url, required): Budgets filed under this group. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/budget-groups' \ --data-urlencode 'q=development' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/budget-groups Create a budget group - MCP id: budget-groups.store - Scope: api:write:budget_groups - Permission: budget_groups::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a budget group and returns it with a `Location` header. Both the name and the icon are required; the icon is an icon name such as `heroicon-o-tag` and is not checked against the available icons, so an unknown name renders as a missing icon in plnnrly. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budgets_count (integer ≥ 0, required): Number of budgets filed under this group. - links (BudgetGroupLinks, required) - self (url, required): The canonical URI of this record. - budgets (url, required): Budgets filed under this group. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/budget-groups' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Development", "icon": "heroicon-o-code-bracket" }' ``` --- # GET /api/v2/budget-groups/{budget_group} Read a budget group - MCP id: budget-groups.show - Scope: api:read:budget_groups - Permission: budget_groups::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one budget group with the number of budgets filed under it and a link to that filtered budget collection. ## Parameters - budget_group (integer, path): The BudgetGroup's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budgets_count (integer ≥ 0, required): Number of budgets filed under this group. - links (BudgetGroupLinks, required) - self (url, required): The canonical URI of this record. - budgets (url, required): Budgets filed under this group. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/budget-groups/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/budget-groups/{budget_group} Update a budget group - MCP id: budget-groups.update - Scope: api:write:budget_groups - Permission: budget_groups::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a budget group: absent keys are untouched and an empty body is rejected. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - budget_group (integer, path): The BudgetGroup's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, optional): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budgets_count (integer ≥ 0, required): Number of budgets filed under this group. - links (BudgetGroupLinks, required) - self (url, required): The canonical URI of this record. - budgets (url, required): Budgets filed under this group. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/budget-groups/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Development and QA" }' ``` --- # DELETE /api/v2/budget-groups/{budget_group} Delete a budget group - MCP id: budget-groups.destroy - Scope: api:destroy:budget_groups - Permission: budget_groups::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Deletes a budget group permanently: it uses no soft deletes, so there is no restore. Every budget in the group is silently ungrouped, and the organisation's Exact item id, VAT code, cost centre and local rate defaults for the group are deleted with it, which changes the output of subsequent project sales invoices. Team preferences referencing the group are left pointing at an id that no longer exists. ## Parameters - budget_group (integer, path): The BudgetGroup's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/budget-groups/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/budgets List budgets - MCP id: budgets.index - Scope: api:read:budgets - Permission: budgets::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's budgets, lowest position first, each with the number of kanban tasks the caller may see. There is no `projects/{project}/budgets` path: pass `filter[project_id]` to read one project's budgets, or `filter[budget_group_id]` to read one group's. Positions are numbered across the whole organisation and renumbered per project in plnnrly, so a page spanning several projects interleaves and ties break by id. Pass `view=detail` for the full payload at three read units instead of one; the two views are identical on this resource. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[budget_group_id] (integer, optional): Budget group the budget is filed under. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[end_date] (date (YYYY-MM-DD), optional): Last day the budget covers. Operators: = gte lte null. - filter[is_billable] (boolean, optional): Whether time logged on this budget is billable. - filter[is_open] (boolean, optional): Whether the budget is open-ended, i.e. its spendable cap is ignored and the over-budget warning is skipped. - filter[project_id] (integer, optional): Owning project. Operators: = in. - filter[start_date] (date (YYYY-MM-DD), optional): First day the budget covers. Operators: = gte lte null. - filter[status] (string, optional): Lifecycle status of the budget. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - budget_group_id (integer or null, required): Budget group this budget is filed under, `null` when ungrouped. - order_column (integer, required, server-assigned): Position within the project as arranged in plnnrly. Set on create to the highest existing position plus one, and renumbered 1..n per project whenever the project's budget page is saved in plnnrly. Not writable. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - start_date (date (YYYY-MM-DD) or null, required): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, required): Last day the budget covers, `null` when it has no end. - budget (integer, required): Budgeted amount of work, in minutes. - spendable (integer, required): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, required): Lifecycle status of the budget. - is_open (boolean, required): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, required): Whether time logged on this budget is billable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this budget belongs to. - budget_group (Ref or BudgetGroupSummary or null, required): The budget group this budget is filed under. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this budget that the caller may see. - links (BudgetLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks filed under this budget. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/budgets' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'filter[is_open]=true' \ --data-urlencode 'sort=order_column' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/budgets Create a budget - MCP id: budgets.store - Scope: api:write:budgets - Permission: budgets::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a budget on the project named in the body and returns it with a `Location` header. `project_id` is settable here and nowhere else: a budget cannot be moved to another project afterwards. budget and spendable are minutes, not money. The position is set by the server to the highest existing position in the organisation plus one. The write also bumps the owning project's `updated_at`, so the project's `ETag` changes and it reappears in an incremental `filter[updated_at][gte]` window, and it is recorded in the project's activity timeline rather than a timeline of its own. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - budget (integer ≥ 0, required): Budgeted amount of work, in minutes. - spendable (integer ≥ 0, optional): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, optional): Lifecycle status of the budget. - is_open (boolean, optional): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, optional): Whether time logged on this budget is billable. - start_date (date (YYYY-MM-DD) or null, optional): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, optional): Last day the budget covers, `null` when it has no end. Must be on or after start_date. - budget_group_id (integer or null → BudgetGroup.id, optional): Budget group this budget is filed under, `null` when ungrouped. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - budget_group_id (integer or null, required): Budget group this budget is filed under, `null` when ungrouped. - order_column (integer, required, server-assigned): Position within the project as arranged in plnnrly. Set on create to the highest existing position plus one, and renumbered 1..n per project whenever the project's budget page is saved in plnnrly. Not writable. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - start_date (date (YYYY-MM-DD) or null, required): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, required): Last day the budget covers, `null` when it has no end. - budget (integer, required): Budgeted amount of work, in minutes. - spendable (integer, required): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, required): Lifecycle status of the budget. - is_open (boolean, required): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, required): Whether time logged on this budget is billable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this budget belongs to. - budget_group (Ref or BudgetGroupSummary or null, required): The budget group this budget is filed under. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this budget that the caller may see. - links (BudgetLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks filed under this budget. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/budgets' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "description": "Front-end development", "budget": 4800, "spendable": 4200, "is_open": false, "is_billable": true }' ``` --- # GET /api/v2/budgets/{budget} Read a budget - MCP id: budgets.show - Scope: api:read:budgets - Permission: budgets::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one budget with its project, its budget group and the number of kanban tasks the caller may see, plus a link to that filtered task collection. budget and spendable are minutes, not money. ## Parameters - budget (integer, path): The Budget's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - budget_group_id (integer or null, required): Budget group this budget is filed under, `null` when ungrouped. - order_column (integer, required, server-assigned): Position within the project as arranged in plnnrly. Set on create to the highest existing position plus one, and renumbered 1..n per project whenever the project's budget page is saved in plnnrly. Not writable. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - start_date (date (YYYY-MM-DD) or null, required): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, required): Last day the budget covers, `null` when it has no end. - budget (integer, required): Budgeted amount of work, in minutes. - spendable (integer, required): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, required): Lifecycle status of the budget. - is_open (boolean, required): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, required): Whether time logged on this budget is billable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this budget belongs to. - budget_group (Ref or BudgetGroupSummary or null, required): The budget group this budget is filed under. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this budget that the caller may see. - links (BudgetLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks filed under this budget. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/budgets/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/budgets/{budget} Update a budget - MCP id: budgets.update - Scope: api:write:budgets - Permission: budgets::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a budget: absent keys are untouched and an empty body is rejected. `project_id` is not accepted, so a budget cannot be moved to another project, and the position is not writable: there is no reorder endpoint. `start_date`, `end_date` and `budget_group_id` accept `null` to clear them. The write bumps the owning project's `updated_at`, so the project's `ETag` changes and it reappears in an incremental `filter[updated_at][gte]` window, and it lands in the project's activity timeline rather than a timeline of its own. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - budget (integer, path): The Budget's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - description (string ≤ 255, optional): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - budget (integer ≥ 0, optional): Budgeted amount of work, in minutes. - spendable (integer ≥ 0, optional): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, optional): Lifecycle status of the budget. - is_open (boolean, optional): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, optional): Whether time logged on this budget is billable. - start_date (date (YYYY-MM-DD) or null, optional): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, optional): Last day the budget covers, `null` when it has no end. Must be on or after start_date. - budget_group_id (integer or null → BudgetGroup.id, optional): Budget group this budget is filed under, `null` when ungrouped. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - budget_group_id (integer or null, required): Budget group this budget is filed under, `null` when ungrouped. - order_column (integer, required, server-assigned): Position within the project as arranged in plnnrly. Set on create to the highest existing position plus one, and renumbered 1..n per project whenever the project's budget page is saved in plnnrly. Not writable. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - start_date (date (YYYY-MM-DD) or null, required): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, required): Last day the budget covers, `null` when it has no end. - budget (integer, required): Budgeted amount of work, in minutes. - spendable (integer, required): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, required): Lifecycle status of the budget. - is_open (boolean, required): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, required): Whether time logged on this budget is billable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this budget belongs to. - budget_group (Ref or BudgetGroupSummary or null, required): The budget group this budget is filed under. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this budget that the caller may see. - links (BudgetLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks filed under this budget. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/budgets/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "budget": 6000, "is_open": false }' ``` --- # DELETE /api/v2/budgets/{budget} Delete a budget - MCP id: budgets.destroy - Scope: api:destroy:budgets - Permission: budgets::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Deletes a budget permanently: it uses no soft deletes, so there is no restore and no force parameter. The delete reaches further: every kanban task on the budget and every legacy invoice row referencing it are deleted with it, and none of those deletions is recorded in an activity log. Sales-invoice rows survive and are detached, their budget reference set to `null`. A budget with logged time answers `409` with `conflict.reason` `has_logged_time`: move or delete the worklogs first. The delete bumps the owning project's `updated_at` and is recorded in the project's activity timeline rather than a timeline of its own. ## Parameters - budget (integer, path): The Budget's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/budgets/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/checkins List checkins - MCP id: checkins.index - Scope: api:read:checkins - Permission: checkins::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1. - Cache-Control: private, no-store Returns attendance records, one per clock-in, newest first. Every operation on this resource requires `checkins::read`, which is the attendance report permission in plnnrly, and a holder sees the whole team; there is no own-rows view for a caller without it. `filter[checked_out_at][null]=true` returns the still-open checkins, which is the only way to ask who is currently clocked in. There is no `week` parameter: filter a week as `filter[checked_in_at][gte]=2026-08-31T00:00:00Z&filter[checked_in_at][lte]=2026-09-06T23:59:59Z`. `meta.page.total` is `null` unless you pass `with_total=1`. `?include=user` expands the employee reference and additionally requires the `users::read` permission and a token scope covering the users prefix; without either it is a `400`, never a `403`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[checked_in_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the employee clocked in. Operators: = gte lte. - filter[checked_out_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the employee clocked out. `null` selects the still-open checkins. Operators: = gte lte null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose checkins to return. Accepts the literal `me`. Naming another employee requires the `checkins::read` permission. Operators: = in. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this checkin belongs to. Assigned from the authenticated user on create and never writable. - user (Ref or UserSummary, required): The employee this checkin belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (CheckinLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/checkins' \ --data-urlencode 'filter[checked_out_at][null]=true' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/checkins Check in - MCP id: checkins.store - Scope: api:write:checkins - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Opens a checkin for the authenticated user and returns it with a `Location` header. `user_id` is not accepted: a checkin always belongs to the caller, and sending the key is a `422`; there is no way to check another employee in, just as there is none in plnnrly. Omit `checked_in_at` to clock in now. Send `checked_out_at` as well to record an already-finished period in one call, which is what a backfill client does. Nothing rejects a second still-open checkin: plnnrly hides its check-in button while one is open, but this endpoint answers `201` either way, so check `filter[checked_out_at][null]=true` first if you need that guarantee. This operation declares no permission of its own, but checking in still requires `checkins::read`, so unlike the check-in button in plnnrly, this endpoint is not open to every employee. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. Must be on or after checked_in_at. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this checkin belongs to. Assigned from the authenticated user on create and never writable. - user (Ref or UserSummary, required): The employee this checkin belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (CheckinLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/checkins' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/checkins/{checkin} Read a checkin - MCP id: checkins.show - Scope: api:read:checkins - Permission: checkins::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one checkin. The summary and detail views are identical on this resource: every field is small enough for a list row. A checkin belonging to another employee is `404` unless the caller holds `checkins::read`; the row is invisible, so its existence stays invisible. `user` renders as an `{"id": N}` reference; `?include=user` expands it, and that expansion additionally requires `users::read` and a token scope covering the users prefix. ## Parameters - checkin (integer, path): The Checkin's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this checkin belongs to. Assigned from the authenticated user on create and never writable. - user (Ref or UserSummary, required): The employee this checkin belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (CheckinLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/checkins/1' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/checkins/{checkin} Check out, or correct a checkin - MCP id: checkins.update - Scope: api:write:checkins - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a checkin: absent keys are untouched, an empty body is rejected. Sending `checked_out_at` is the checkout: there is no `actions/check-out` route, because the write only ever fails on an invalid value. Send `checked_out_at: null` to re-open a closed checkin. `user_id` is not accepted, so a checkin cannot be moved to another employee. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. This operation declares no permission of its own, but it still requires `checkins::read`; a caller without it gets `404` on every checkin, their own included. ## Parameters - checkin (integer, path): The Checkin's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. Must be on or after checked_in_at. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this checkin belongs to. Assigned from the authenticated user on create and never writable. - user (Ref or UserSummary, required): The employee this checkin belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (CheckinLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/checkins/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "checked_out_at": "2026-08-31T16:48:00Z" }' ``` --- # DELETE /api/v2/checkins/{checkin} Delete a checkin - MCP id: checkins.destroy - Scope: api:destroy:checkins - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a checkin. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Nothing blocks the delete: a checkin carries no dependants, and removing one simply takes the period out of the attendance report. This operation declares no permission of its own, but it still requires `checkins::read`; a caller without it gets `404` on every checkin. ## Parameters - checkin (integer, path): The Checkin's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/checkins/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/comments/{comment} Read a comment - MCP id: comments.show - Scope: api:read:project_management - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one comment in the detail view, reachable by id from either parent. The `commentable_type` / `commentable_id` pair names the record it hangs off; the commentable itself is a reference and is not expandable. Reading a comment requires being able to read its parent, so the permission depends on what the comment hangs off: `project_management::view_backlog` (checked as `project_management::manage_work_items`) for a comment on a work item, and the drive prefix's access permission (`project_drive::access`, `customer_drive::access`, `organization_drive::access` or `user_drive::access`, whichever the document sits under) for a comment on a drive item. The token must cover that same prefix: a token without it gets `404`, not `403`. Comments are hard-deleted, so a deleted one is simply 404 and `trashed` is not an accepted parameter. ## Parameters - comment (integer, path): The Comment's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/comments/1' \ --data-urlencode 'include=author' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/comments/{comment} Edit a comment - MCP id: comments.update - Scope: api:write:project_management - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one comment: `body` is the only writable key and absent keys are untouched. This mirrors the edit action on a comment thread in plnnrly and follows the same rules: Editing is author-only. No permission lets a manager edit somebody else's comment, so a PATCH by a non-author is `403` `forbidden-by-policy` and there is no override to ask for. On top of authorship the caller must still be able to comment on the parent, which is `project_management::manage_work_items` for a work item and read access to the drive document for a drive item; the token must cover that parent's prefix. Re-parenting is not a field write and is not available: sending `commentable_type` or `commentable_id` is a `422`. Mention spans present in the body you read may be sent back verbatim and are preserved; note that the response body is sanitised, so it can differ from the exact bytes you sent. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`; a comment has no `version` field, so its `ETag` is derived from `updated_at`, and the `ETag` in the response is the one to send next. ## Parameters - comment (integer, path): The Comment's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - body (string ≤ 65535, optional): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: You did not write this comment. Only its author can edit a comment, and no permission lets anyone else do it. You also get this answer when you can see the work item or drive item the comment belongs to but may not comment on it. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/comments/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "body": "

Deployed to staging, ready for review.

" }' ``` --- # DELETE /api/v2/comments/{comment} Delete a comment - MCP id: comments.destroy - Scope: api:destroy:project_management - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes one comment and answers `204`. Deleting is author-only, on the same terms as editing: no permission lets a manager delete somebody else's comment, a DELETE by a non-author is `403` `forbidden-by-policy`, and the caller must additionally be able to comment on the parent (`project_management::manage_work_items` for a work item, read access to the drive document for a drive item), with a token covering that parent's prefix. There is no soft delete, no ?force parameter and no restore verb: a comment comes back only by being posted again, as a new comment with a new id and a new timestamp. A second DELETE of the same id is `404`. ## Parameters - comment (integer, path): The Comment's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: You did not write this comment. Only its author can delete a comment, and no permission lets anyone else do it. You also get this answer when you can see the work item or drive item the comment belongs to but may not comment on it. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/comments/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/company-branches List company branches - MCP id: company-branches.index - Scope: api:read:company_branches - Permission: company_branches::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's company branches, sorted by name A to Z. Each row carries the number of customers assigned to the branch and a link to that filtered customer collection. Search with `q=` over the branch name. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers assigned to this branch. - links (CompanyBranchLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers assigned to this branch. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/company-branches' \ --data-urlencode 'q=Rotterdam' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/company-branches Create a company branch - MCP id: company-branches.store - Scope: api:write:company_branches - Permission: company_branches::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a branch and returns it in the detail view with a `Location` header. Names are not required to be unique, as in plnnrly. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers assigned to this branch. - links (CompanyBranchLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers assigned to this branch. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/company-branches' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Rotterdam" }' ``` --- # GET /api/v2/company-branches/{company_branch} Read a company branch - MCP id: company-branches.show - Scope: api:read:company_branches - Permission: company_branches::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one branch in the detail view. Branches are hard-deleted, so a deleted branch is gone and answers `404`. ## Parameters - company_branch (integer, path): The CompanyBranch's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers assigned to this branch. - links (CompanyBranchLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers assigned to this branch. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/company-branches/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/company-branches/{company_branch} Update a company branch - MCP id: company-branches.update - Scope: api:write:company_branches - Permission: company_branches::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a branch: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - company_branch (integer, path): The CompanyBranch's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Branch name as shown in the UI, e.g. Rotterdam. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers assigned to this branch. - links (CompanyBranchLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers assigned to this branch. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/company-branches/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Rotterdam Zuid" }' ``` --- # DELETE /api/v2/company-branches/{company_branch} Delete a company branch - MCP id: company-branches.destroy - Scope: api:destroy:company_branches - Permission: company_branches::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a branch: branches are not soft-deleted and there is no restore. Customers assigned to the branch are un-assigned rather than blocked, so a branch that is in use deletes successfully. ## Parameters - company_branch (integer, path): The CompanyBranch's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/company-branches/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/contact-roles List contact roles - MCP id: contact-roles.index - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the contact roles the caller may read, by name A-Z. Roles are one list for the whole organisation, so the whole list is one short page. Nothing is includable on this resource: pass q to search the name, and read `contacts_count` or follow `links.contacts` for the contacts assigned a role. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - contacts_count (integer ≥ 0, required): Number of contacts assigned this role. - links (ContactRoleLinks, required) - self (url, required): The canonical URI of this record. - contacts (url, required): Contacts assigned this role. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/contact-roles' \ --data-urlencode 'q=invoic' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/contact-roles Create a contact role - MCP id: contact-roles.store - Scope: api:write:customers - Permission: customers::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a contact role and returns it with a `Location` header. The name must be unique across the organisation, as in plnnrly. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required, unique): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. Must be unique across ContactRole. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - contacts_count (integer ≥ 0, required): Number of contacts assigned this role. - links (ContactRoleLinks, required) - self (url, required): The canonical URI of this record. - contacts (url, required): Contacts assigned this role. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/contact-roles' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Invoicing" }' ``` --- # GET /api/v2/contact-roles/{contact_role} Read a contact role - MCP id: contact-roles.show - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one contact role. Every field is in both views, so `view=detail` returns the identical shape. The response carries a strong `ETag`; send it back as `If-None-Match` to get a `304`. ## Parameters - contact_role (integer, path): The ContactRole's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - contacts_count (integer ≥ 0, required): Number of contacts assigned this role. - links (ContactRoleLinks, required) - self (url, required): The canonical URI of this record. - contacts (url, required): Contacts assigned this role. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/contact-roles/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/contact-roles/{contact_role} Update a contact role - MCP id: contact-roles.update - Scope: api:write:customers - Permission: customers::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a contact role: absent keys are untouched, and name is the only writable key. Renaming a role changes it for every contact assigned it. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - contact_role (integer, path): The ContactRole's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional, unique): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. Must be unique across ContactRole. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - contacts_count (integer ≥ 0, required): Number of contacts assigned this role. - links (ContactRoleLinks, required) - self (url, required): The canonical URI of this record. - contacts (url, required): Contacts assigned this role. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/contact-roles/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Invoicing contact" }' ``` --- # DELETE /api/v2/contact-roles/{contact_role} Delete a contact role - MCP id: contact-roles.destroy - Scope: api:destroy:customers - Permission: customers::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a contact role and returns `204`. Every contact holding the role is unassigned rather than the delete being refused, exactly as in plnnrly. Roles are not soft-deleted, so this cannot be reversed and there is no force parameter. ## Parameters - contact_role (integer, path): The ContactRole's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/contact-roles/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/contacts List contacts - MCP id: contacts.index - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the contacts the caller may read, by name A-Z. Pass `filter[contactable_type]` and `filter[contactable_id]` together to list one customer's or one user's contacts (there is no nested path for them), or `filter[contact_role_id]` to list one role's. Pass q to search name and email, and `include=contactable,role` to expand the parent and the role. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[contact_role_id] (integer, optional): Assigned contact role. Use the `null` operator to list contacts with no role. Operators: = in null. - filter[contactable_id] (integer, optional): Key of the parent record. This pair is how a customer's contacts are listed: there is no `/customers/{customer}/contacts` path. Operators: = in. - filter[contactable_type] (string, optional): Kind of parent the contact hangs off. Must be sent together with `filter[contactable_id]`; one half without the other is a `400`. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`, `email`, in that order of relevance. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - contactable_type (string, required): The kind of record `contactable` points at; it says which branch of `contactable` applies. - contactable_id (integer, required): The id of the record `contactable` points at. - contactable (Ref or CustomerSummary or UserSummary, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contact_role_id (integer or null, required): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - role (Ref or ContactRoleSummary or null, required): The contact's role. Rendered as {"id": N} until named in `?include=role`, and `null` where no role is assigned. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (string or null ≤ 255, required): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, required): Display order within one parent, ascending. Null where unset. - comment (html or null, required): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ContactLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/contacts' \ --data-urlencode 'filter[contactable_type]=customer' \ --data-urlencode 'filter[contactable_id]=1' \ --data-urlencode 'include=role' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/contacts Create a contact - MCP id: contacts.store - Scope: api:write:customers - Permission: customers::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a contact under a customer or a user. `contactable_type` and `contactable_id` are required together: the type is an alias (customer or user) and the id must name a record the caller may read, which for a user additionally requires the users prefix. Returns `201` with a `Location` header. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - contactable_type (string, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contactable_id (integer, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. Required when contactable_type is present. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (email or null ≤ 255, optional): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, optional): Phone number. - contact_role_id (integer or null → ContactRole.id, optional): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - address (string or null ≤ 255, optional): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, optional): Postal code. - city (string or null ≤ 255, optional): City. - country (string or null ≤ 255, optional): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, optional): Display order within one parent, ascending. Null where unset. - comment (string or null, optional): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - contactable_type (string, required): The kind of record `contactable` points at; it says which branch of `contactable` applies. - contactable_id (integer, required): The id of the record `contactable` points at. - contactable (Ref or CustomerSummary or UserSummary, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contact_role_id (integer or null, required): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - role (Ref or ContactRoleSummary or null, required): The contact's role. Rendered as {"id": N} until named in `?include=role`, and `null` where no role is assigned. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (string or null ≤ 255, required): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, required): Display order within one parent, ascending. Null where unset. - comment (html or null, required): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ContactLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/contacts' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "contactable_type": "customer", "contactable_id": 1, "name": "Marieke de Vries", "email": "marieke@example.test", "phone": "+31 20 123 4567", "comment": "

Prefers email.

" }' ``` --- # GET /api/v2/contacts/{contact} Read a contact - MCP id: contacts.show - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one contact in the detail view. The response carries a strong `ETag` derived from `updated_at`; send it back as `If-None-Match` to get a `304`. ## Parameters - contact (integer, path): The Contact's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - contactable_type (string, required): The kind of record `contactable` points at; it says which branch of `contactable` applies. - contactable_id (integer, required): The id of the record `contactable` points at. - contactable (Ref or CustomerSummary or UserSummary, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contact_role_id (integer or null, required): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - role (Ref or ContactRoleSummary or null, required): The contact's role. Rendered as {"id": N} until named in `?include=role`, and `null` where no role is assigned. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (string or null ≤ 255, required): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, required): Display order within one parent, ascending. Null where unset. - comment (html or null, required): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ContactLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/contacts/1' \ --data-urlencode 'include=contactable,role' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/contacts/{contact} Update a contact - MCP id: contacts.update - Scope: api:write:customers - Permission: customers::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a contact: absent keys are untouched and an empty body is a `422`. `contactable_type` and `contactable_id` are not accepted: re-parenting a contact is not supported, so sending either is a `422`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - contact (integer, path): The Contact's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (email or null ≤ 255, optional): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, optional): Phone number. - contact_role_id (integer or null → ContactRole.id, optional): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - address (string or null ≤ 255, optional): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, optional): Postal code. - city (string or null ≤ 255, optional): City. - country (string or null ≤ 255, optional): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, optional): Display order within one parent, ascending. Null where unset. - comment (string or null, optional): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - contactable_type (string, required): The kind of record `contactable` points at; it says which branch of `contactable` applies. - contactable_id (integer, required): The id of the record `contactable` points at. - contactable (Ref or CustomerSummary or UserSummary, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contact_role_id (integer or null, required): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - role (Ref or ContactRoleSummary or null, required): The contact's role. Rendered as {"id": N} until named in `?include=role`, and `null` where no role is assigned. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (string or null ≤ 255, required): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, required): Display order within one parent, ascending. Null where unset. - comment (html or null, required): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ContactLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/contacts/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "phone": "+31 20 765 4321" }' ``` --- # DELETE /api/v2/contacts/{contact} Delete a contact - MCP id: contacts.destroy - Scope: api:destroy:customers - Permission: customers::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a contact and returns `204`. Contacts are not soft-deleted, so this cannot be reversed, there is no force parameter and there is no restore action. ## Parameters - contact (integer, path): The Contact's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/contacts/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/customer-categories List customer categories - MCP id: customer-categories.index - Scope: api:read:customer_categories - Permission: customer_categories::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the customer-category taxonomy, sorted by name A to Z. Each row carries the number of customers in the category and a link to that filtered customer collection. Search with `q=` over the category name. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, required, multiline): Free-text note on what belongs in this category. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers in this category. - links (CustomerCategoryLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers in this category. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customer-categories' \ --data-urlencode 'q=Enterprise' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customer-categories Create a customer category - MCP id: customer-categories.store - Scope: api:write:customer_categories - Permission: customer_categories::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a category and returns it in the detail view with a `Location` header. Names are not required to be unique, as in plnnrly. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, optional): Free-text note on what belongs in this category. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, required, multiline): Free-text note on what belongs in this category. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers in this category. - links (CustomerCategoryLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers in this category. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customer-categories' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Enterprise", "description": "Accounts over 500 seats." }' ``` --- # GET /api/v2/customer-categories/{customer_category} Read a customer category - MCP id: customer-categories.show - Scope: api:read:customer_categories - Permission: customer_categories::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one category in the detail view. Categories are hard-deleted, so a deleted category is gone and answers `404`. ## Parameters - customer_category (integer, path): The CustomerCategory's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, required, multiline): Free-text note on what belongs in this category. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers in this category. - links (CustomerCategoryLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers in this category. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/customer-categories/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/customer-categories/{customer_category} Update a customer category - MCP id: customer-categories.update - Scope: api:write:customer_categories - Permission: customer_categories::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a category: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - customer_category (integer, path): The CustomerCategory's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Category name as shown in the UI, e.g. Enterprise. - description (string or null, optional): Free-text note on what belongs in this category. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, required, multiline): Free-text note on what belongs in this category. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers in this category. - links (CustomerCategoryLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers in this category. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/customer-categories/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Key accounts" }' ``` --- # DELETE /api/v2/customer-categories/{customer_category} Delete a customer category - MCP id: customer-categories.destroy - Scope: api:destroy:customer_categories - Permission: customer_categories::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a category: categories are not soft-deleted and there is no restore. Customers in the category are un-categorised rather than blocked, so a category that is in use deletes successfully. ## Parameters - customer_category (integer, path): The CustomerCategory's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/customer-categories/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/customer-subscriptions List customer subscriptions - MCP id: customer-subscriptions.index - Scope: api:read:customer_subscriptions - Permission: customer_subscriptions::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the organisation's customer subscriptions, newest start date first. Defaults to the summary view; `view=detail` carries the same fields on this resource (no field of it is detail-only) and costs three read units instead of one. `filter[status]=active` with `filter[auto_renew]=true` and `filter[next_invoice_date][lte]=` is the due-for-invoicing query the renewal run uses. Search with `q=` over the subscription description, and pass `trashed=with|only` to see deleted subscriptions. There is no relationship sort: use `sort=customer_id` or `filter[customer_id]` rather than `customer.name`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[auto_renew] (boolean, optional): Whether the renewal run may invoice the subscription again. - filter[billing_interval] (string, optional): Interval the subscription is invoiced at. Operators: = ne in. - filter[cancelled_at] (date (YYYY-MM-DD), optional): Day the subscription was cancelled. `null` selects the ones that were not. Operators: = gte lte null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[customer_id] (integer, optional): Customer the subscription belongs to. This is how you list one customer's subscriptions: there is no nested path for them. Operators: = in. - filter[expires_at] (date (YYYY-MM-DD), optional): Day the subscription runs out. `null` selects the open-ended ones. Operators: = gte lte null. - filter[last_invoiced_at] (date (YYYY-MM-DD), optional): Day the subscription was last invoiced. `null` selects the ones that never were. Operators: = gte lte null. - filter[next_invoice_date] (date (YYYY-MM-DD), optional): First day of the period to be invoiced next. `null` selects the subscriptions the renewal run skips. Combined with `filter[status]=active` and `filter[auto_renew]=true` this is the due-for-invoicing query the renewal run itself uses, and it is the single most useful query on this resource. Operators: = gte lte null. - filter[starts_at] (date (YYYY-MM-DD), optional): First day the subscription runs from. Combine gte and lte to select a period, e.g. the subscriptions that started in one month. Operators: = gte lte. - filter[status] (string, optional): Lifecycle status. Only `active` subscriptions are invoiced by the renewal run. Operators: = ne in. - filter[subscription_product_id] (integer, optional): Subscriptions with at least one line selling this subscription product, which is the Products filter in plnnrly. Lines that bill a hosting domain or a server are not products and never match. Operators: = in. - filter[subscription_type_id] (integer, optional): Subscription type the subscription is an instance of. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customer-subscriptions' \ --data-urlencode 'filter[status]=active' \ --data-urlencode 'filter[auto_renew]=true' \ --data-urlencode 'filter[next_invoice_date][lte]=2026-12-31' \ --data-urlencode 'sort=-starts_at' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customer-subscriptions Create a customer subscription - MCP id: customer-subscriptions.store - Scope: api:write:customer_subscriptions - Permission: customer_subscriptions::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a subscription and returns it in the detail view with a `Location` header. Its lines are not created here: nested writes are not accepted, so send `items` as separate POSTs to `customer-subscriptions/{customer_subscription}/items`; a subscription with no lines is valid and simply invoices nothing. `starts_at` is required: it has no default. Sending `auto_renew=true` together with `billing_interval=one-time` succeeds and comes back as `auto_renew=false`, because a one-off has no next period. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - customer_id (integer → Customer.id, required): Customer this subscription belongs to. - subscription_type_id (integer → SubscriptionType.id, required): Subscription type this subscription is an instance of. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - description (string or null, optional): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, optional): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, optional): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - next_invoice_date (date (YYYY-MM-DD) or null, optional): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, optional): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, optional): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, optional): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customer-subscriptions' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "customer_id": 1, "subscription_type_id": 1, "description": "Managed hosting and maintenance.", "billing_interval": "yearly", "starts_at": "2026-07-01", "auto_renew": true }' ``` --- # GET /api/v2/customer-subscriptions/{customer_subscription} Read a customer subscription - MCP id: customer-subscriptions.show - Scope: api:read:customer_subscriptions - Permission: customer_subscriptions::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one subscription in the detail view. Subscriptions are soft-deleted, so a deleted subscription is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live subscription. Its lines are not embedded: follow `links.items`, and its invoices are at `sales-invoices?filter[customer_subscription_id]={id}`. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customer-subscriptions/1' \ --data-urlencode 'include=customer,subscription_type' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/customer-subscriptions/{customer_subscription} Update a customer subscription - MCP id: customer-subscriptions.update - Scope: api:write:customer_subscriptions - Permission: customer_subscriptions::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a subscription: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Switching `billing_interval` to one-time also sets `auto_renew` to `false`, whatever is sent alongside it; this holds on every save. Clearing `next_invoice_date` to `null` takes the subscription out of the renewal run without cancelling it. Lines are edited through their own sub-resource, never from here. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - customer_id (integer → Customer.id, optional): Customer this subscription belongs to. - subscription_type_id (integer → SubscriptionType.id, optional): Subscription type this subscription is an instance of. - description (string or null, optional): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, optional): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, optional): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), optional): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, optional): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, optional): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, optional): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, optional): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/customer-subscriptions/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "status": "cancelled", "cancelled_at": "2026-09-30", "auto_renew": false }' ``` --- # DELETE /api/v2/customer-subscriptions/{customer_subscription} Delete a customer subscription - MCP id: customer-subscriptions.destroy - Scope: api:destroy:customer_subscriptions - Permission: customer_subscriptions::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes a subscription: it is only marked deleted, and POST `customer-subscriptions/{customer_subscription}/actions/restore` reverses it. There is no permanent delete: a subscription that has ever been invoiced is referenced by those sales invoices, which is why `?force=` is rejected. A deleted subscription is skipped by the renewal run. Its lines are not deleted with it: they stay attached and come back with the subscription when it is restored. Sales invoices already raised from it are untouched and keep pointing at it. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/customer-subscriptions/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customer-subscriptions/{customer_subscription}/actions/restore Restore a deleted customer subscription - MCP id: customer-subscriptions.actions.restore - Scope: api:write:customer_subscriptions - Permission: customer_subscriptions::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the subscription in the detail view. Restoring additionally requires `customer_subscriptions::delete` as well as `customer_subscriptions::create`. Restoring a subscription that is not deleted succeeds and returns an `already_in_target_state` warning. The restore finds a deleted subscription on its own, so the trashed parameter is not accepted. The subscription's lines are not touched: they were soft-deleted separately, if at all, and are restored through their own sub-resource. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customer-subscriptions/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/customer-subscriptions/{customer_subscription}/items List a subscription's lines - MCP id: customer-subscriptions.items.index - Scope: api:read:customer_subscriptions - Permission: customer_subscriptions::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns one customer subscription's lines in their printed order, `sort_order` ascending. There is no organisation-wide collection of subscription lines: a line only means anything under the subscription it belongs to, so this nested path is the only way to list them and `filter[customer_subscription_id]` does not exist. A deleted subscription answers `404` for the whole path unless `trashed=with` is passed, which also includes the subscription's deleted lines. Defaults to the summary view; pass `include=customer_subscription` or `include=subscribable` to expand what each line points at. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[subscribable_id] (integer, optional): Id of the record this line sells. Send together with `subscribable_type`. Operators: = in null. - filter[subscribable_type] (string, optional): What kind of record this line sells. Send together with `subscribable_id`. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_subscription_id (integer, required, server-assigned): The subscription this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another subscription. - customer_subscription (Ref or CustomerSubscriptionSummary, required): The subscription this line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. - description (string or null, required, multiline): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer, required): Number of units billed per period. - purchase_price (decimal (2 dp) ≥ 0, required): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. - sales_price (decimal (2 dp) ≥ 0, required): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line, computed by the server: quantity x `sales_price` x `vat_rate` / 100. Read-only. - total_amount (decimal (2 dp), required, server-assigned): Line total including VAT (quantity x `sales_price` plus the VAT above), computed by the server. Note this is the opposite convention from `SalesInvoiceRow.total_amount`, which excludes VAT: adding VAT to this value double-counts it. Read-only. - sort_order (integer, required, server-assigned): Position of the line within its subscription, and the default sort key. Assigned on create; there is no way to reorder lines through the API. Only the ordering is meaningful: values are not unique across subscriptions. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. This is also the `ETag` revision: a subscription line has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customer-subscriptions/1/items' \ --data-urlencode 'trashed=with' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customer-subscriptions/{customer_subscription}/items Add a line to a subscription - MCP id: customer-subscriptions.items.store - Scope: api:write:customer_subscriptions - Permission: customer_subscriptions::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one line on the subscription named in the path and returns it in the detail view, with a `Location` header pointing at the flat item path. The subscription comes from the path, so `customer_subscription_id` is not accepted in the body and a line can never be moved between subscriptions. `sort_order` is assigned by the server and appends the line to the end. `vat_amount` and `total_amount` are computed by the server from quantity, `sales_price` and `vat_rate` and are never accepted; note that `total_amount` includes VAT here, unlike a sales-invoice row. `subscribable_type` and `subscribable_id` are sent together or not at all, and `subscription_product` is the only type this API can attach: lines that bill a hosting domain or a server can be created in plnnrly only. A deleted subscription answers `404`. `exact_item_id` and the other Exact codes are optional: a renewal falls back to the product's own Exact values and then to the organisation's defaults. ## Parameters - customer_subscription (integer, path): The CustomerSubscription's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - description (string or null, optional): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer ≥ 1, optional): Number of units billed per period. - purchase_price (decimal (2 dp) 0 to 99999999.99, optional): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. At least 0. At most 99999999.99. - sales_price (decimal (2 dp) 0 to 99999999.99, optional): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. At least 0. At most 99999999.99. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - subscribable_type (string or null, optional): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. Required when subscribable_id is present. - subscribable_id (integer or null → SubscriptionProduct.id, optional): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. Required when subscribable_type is present. - exact_item_id (string or null ≤ 255, optional): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, optional): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost-centre code. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - customer_subscription_id (integer, required, server-assigned): The subscription this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another subscription. - customer_subscription (Ref or CustomerSubscriptionSummary, required): The subscription this line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. - description (string or null, required, multiline): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer, required): Number of units billed per period. - purchase_price (decimal (2 dp) ≥ 0, required): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. - sales_price (decimal (2 dp) ≥ 0, required): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line, computed by the server: quantity x `sales_price` x `vat_rate` / 100. Read-only. - total_amount (decimal (2 dp), required, server-assigned): Line total including VAT (quantity x `sales_price` plus the VAT above), computed by the server. Note this is the opposite convention from `SalesInvoiceRow.total_amount`, which excludes VAT: adding VAT to this value double-counts it. Read-only. - sort_order (integer, required, server-assigned): Position of the line within its subscription, and the default sort key. Assigned on create; there is no way to reorder lines through the API. Only the ordering is meaningful: values are not unique across subscriptions. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. This is also the `ETag` revision: a subscription line has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customer-subscriptions/1/items' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "description": "Webhosting {{$domain}}", "quantity": 1, "purchase_price": "4.50", "sales_price": "12.50", "subscribable_type": "subscription_product", "subscribable_id": 1 }' ``` --- # GET /api/v2/customers List customers - MCP id: customers.index - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1 (to-many +3), `q` +2. - Cache-Control: private, no-store Returns the customers the caller may read, by name A-Z. Defaults to the summary view; `view=detail` carries the same fields on this resource (no field of it is detail-only) and costs three read units instead of one. Search with `q=` over name, code and email, filter on the five reference ids, the status, the VAT flag and the three dates, and pass `trashed=with|only` to see deleted customers. `filter[account_manager_id]=me` is the "my customers" query. There is no relationship sort: `sort=customer_category.name` is a `400`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[account_manager_id] (integer, optional): Responsible account manager. Accepts the literal `me`. Use the `null` operator to list customers with no account manager. Operators: = in null. - filter[company_branch_id] (integer, optional): Industry branch. Operators: = in null. - filter[company_size_id] (integer, optional): Company size band. Operators: = in null. - filter[company_type_id] (integer, optional): Company type. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[customer_category_id] (integer, optional): Customer category. Use the `null` operator to list uncategorised customers. Operators: = in null. - filter[customer_since] (date (YYYY-MM-DD), optional): Day this customer became a customer. Operators: = gte lte null. - filter[exact_keep_local_address] (boolean, optional): Whether the Exact sync leaves this customer's local address alone. - filter[exact_synced_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the Exact sync last handled this customer. Use the `null` operator to list customers the sync has never touched. Operators: = gte lte null. - filter[founded_at] (date (YYYY-MM-DD), optional): Day the customer's company was founded. Operators: = gte lte null. - filter[is_vat_exempt] (boolean, optional): Whether invoices to this customer are issued without VAT. - filter[status] (string, optional): Where the customer sits in the sales lifecycle. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`, `code`, `email`, in that order of relevance. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customers' \ --data-urlencode 'filter[status]=active' \ --data-urlencode 'filter[account_manager_id]=me' \ --data-urlencode 'sort=customer_since' \ --data-urlencode 'include=customer_category,tags' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customers Create a customer - MCP id: customers.store - Scope: api:write:customers - Permission: customers::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a customer and returns it in the detail view with a `Location` header. name, code, color, status and `account_manager_id` are required: the first four have no default, and an account manager is mandatory on the customer form in plnnrly. code must be unique. color must be a CSS hex triplet or sextet; any other value would read back as #6b7280. `tag_ids` replaces the whole tag set and may be omitted. Sending address, zip or city geocodes the address server-side and fills lat and lng, which are not writable. `debtor_code` additionally requires `billing_settings::update`. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required, unique): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. Must be unique across Customer. - color (string, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - account_manager_id (integer → User.id, required): Responsible account manager. Required on create; unassigned when the user is deleted. - email (email or null ≤ 255, optional): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, optional): Phone number. - address (string or null ≤ 255, optional): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, optional): Postal code. - city (string or null ≤ 255, optional): City. - country (string or null ≤ 255, optional): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - founded_at (date (YYYY-MM-DD) or null, optional): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, optional): Day this customer became a customer. - is_vat_exempt (boolean, optional): Whether invoices to this customer are issued without VAT. - debtor_code (string or null ≤ 255, optional): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - customer_category_id (integer or null → CustomerCategory.id, optional): Customer category, or `null`. - company_branch_id (integer or null → CompanyBranch.id, optional): Industry branch, or `null`. - company_type_id (integer or null → id, optional): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null → id, optional): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customers' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Acme Industries", "code": "ACM", "color": "#1d4ed8", "status": "active", "email": "hello@acme.test", "address": "Keizersgracht 1", "zip": "1015 CJ", "city": "Amsterdam", "country": "NL", "customer_since": "2026-01-15", "account_manager_id": 1 }' ``` --- # GET /api/v2/customers/{customer} Read a customer - MCP id: customers.show - Scope: api:read:customers - Permission: customers::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one customer in the detail view. The response carries a strong `ETag` derived from `updated_at`; send it back as `If-None-Match` to get a `304`. A trashed customer is `404` unless `trashed=with` is passed, which needs only `customers::read`. ## Parameters - customer (integer, path): The Customer's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/customers/1' \ --data-urlencode 'include=customer_category,company_branch,tags' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/customers/{customer} Update a customer - MCP id: customers.update - Scope: api:write:customers - Permission: customers::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a customer: absent keys are untouched and an empty body is a `422`. `tag_ids` replaces the whole tag set, and an empty array clears it. Sending address, zip or city re-geocodes the address and overwrites lat and lng, neither of which is writable. `debtor_code` additionally requires `billing_settings::update`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - customer (integer, path): The Customer's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, optional, unique): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. Must be unique across Customer. - color (string, optional): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, optional): Where the customer sits in the sales lifecycle. - email (email or null ≤ 255, optional): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, optional): Phone number. - address (string or null ≤ 255, optional): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, optional): Postal code. - city (string or null ≤ 255, optional): City. - country (string or null ≤ 255, optional): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - founded_at (date (YYYY-MM-DD) or null, optional): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, optional): Day this customer became a customer. - is_vat_exempt (boolean, optional): Whether invoices to this customer are issued without VAT. - debtor_code (string or null ≤ 255, optional): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - account_manager_id (integer or null → User.id, optional): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null → CustomerCategory.id, optional): Customer category, or `null`. - company_branch_id (integer or null → CompanyBranch.id, optional): Industry branch, or `null`. - company_type_id (integer or null → id, optional): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null → id, optional): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/customers/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "status": "inactive" }' ``` --- # DELETE /api/v2/customers/{customer} Delete a customer - MCP id: customers.destroy - Scope: api:destroy:customers - Permission: customers::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes a customer and returns `204`; `POST customers/{customer}/actions/restore` reverses it. The DELETE cascades to projects: a soft delete also soft-deletes every project of this customer, and actions/restore does not bring them back; restore each one through `POST projects/{project}/actions/restore`. There is no permanent delete: `force=true` answers `400` `unsupported-parameter`. Deleting an already-deleted customer answers `204`, so a retry is safe. ## Parameters - customer (integer, path): The Customer's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/customers/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/customers/{customer}/actions/restore Restore a deleted customer - MCP id: customers.actions.restore - Scope: api:write:customers - Permission: customers::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the customer in the detail view. Restoring additionally requires `customers::delete` as well as `customers::create`. Restoring a customer that is not deleted succeeds and returns an `already_in_target_state` warning. The customer's projects are not restored: deleting the customer soft-deleted each of them, and each is brought back through `POST projects/{project}/actions/restore`. The restore finds a deleted customer on its own, so the trashed parameter is not accepted. ## Parameters - customer (integer, path): The Customer's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/customers/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/drive-documents List document bodies - MCP id: drive-documents.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1. - Cache-Control: private, no-store Returns the bodies of the drive documents whose owning drive item the caller may read, most recently written first. A body is visible exactly when the item it belongs to is: only the bodies of drive items you can read are listed, so this collection can be read across the whole organisation without leaking a document the caller cannot open. Narrow it to one item, or to a list of them, with `filter[drive_item_id]`. The body itself is not in the default summary view: `content` is detail-only, so pass `?view=detail` to get it, which costs 3 read units. Bodies served here may lag slightly behind a document that is being edited live, because the collection does not collect unsaved edits from the live editing session; `GET /api/v2/drive-documents/{drive_document}` does. `total` is omitted unless you pass `with_total=1`. There is no `q` on this resource and no soft delete, so neither `q` nor `trashed` is an accepted parameter. The declared permission is `project_drive::access`; `customer_drive`, `organization_drive` and `user_drive` apply by the owning item's owner type, and a token covering any of the four is accepted. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[drive_item_id] (integer, optional): The drive item whose body you want. Accepts a list, so a client that listed drive items can fetch all their bodies in one call. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Id of the document body, not of the drive item it belongs to. The two are numbered separately. - drive_item_id (integer, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (markdown or null, required, detail only): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. Typing in the collaborative editor moves this, not the drive item's own `updated_at`. - links (DriveDocumentLinks, required) - self (url, required): The canonical URI of this record. - content_html (html or null, optional, on request): The same document as sanitised HTML, preserving the text colours, highlights, alignment and @mentions Markdown cannot carry. Mention spans round-trip unchanged; constructing a new one is out of scope for v2.0. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-documents' \ --data-urlencode 'filter[drive_item_id]=1' \ --data-urlencode 'view=detail' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-documents Create a document body - MCP id: drive-documents.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Gives an existing bodyless drive item a body, and returns it with a `Location` header. This is the second half of creating a document: `POST /api/v2/drive-items` creates the item, and this endpoint fills it in. `content` is GitHub-Flavored Markdown and may be omitted for an empty document. Two refusals: an item that already has a body answers `409` `duplicate-resource`, because a drive item has at most one, and an item that is not an editable document (an uploaded PDF, image or Office file) answers `422` on `/drive_item_id`, since there is no editor schema to hold a body. An item the caller cannot read is refused exactly as a nonexistent one is. Because the body did not exist a moment ago, no collaboration room can be open on it, so the content is stored directly and the editor adopts it on first open. The declared permission is `project_drive::create`; the other three drive prefixes apply by the owning item's owner type, and the record rule, edit access on that item, is what actually decides. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - drive_item_id (integer → DriveItem.id, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (string or null ≤ 262144, optional): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Id of the document body, not of the drive item it belongs to. The two are numbered separately. - drive_item_id (integer, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (markdown or null, required, detail only): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. Typing in the collaborative editor moves this, not the drive item's own `updated_at`. - links (DriveDocumentLinks, required) - self (url, required): The canonical URI of this record. - content_html (html or null, optional, on request): The same document as sanitised HTML, preserving the text colours, highlights, alignment and @mentions Markdown cannot carry. Mention spans round-trip unchanged; constructing a new one is out of scope for v2.0. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 duplicate-resource | idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-documents' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "drive_item_id": 1, "content": "# Kick-off\n\n- [ ] Confirm scope with the client\n- [x] Book the room\n" }' ``` --- # GET /api/v2/drive-documents/{drive_document} Read a document body - MCP id: drive-documents.show - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one document body in the detail view: `content` as GitHub-Flavored Markdown, plus `?include=content_html` for the sanitised-HTML projection that also carries the colours, highlights, alignment and @mentions Markdown cannot express. Read that projection first if you intend to write the document back, because a Markdown round-trip discards all four. This endpoint first collects any edits still pending in a live editing session, so the body and the `ETag` describe what has actually been typed; if the collaboration server is unreachable it serves the last stored body instead of failing. The drive item's own metadata (title, folder, tags, owner, effective access) is not repeated here: read it from `GET /api/v2/drive-items/{drive_item}`, following `drive_item_id`. There is no soft delete on this resource, so neither `trashed` nor `force` is an accepted parameter. The declared permission is `project_drive::access`; the other three drive prefixes apply by the owning item's owner type, and the per-item rule is what actually decides. ## Parameters - drive_document (integer, path): The DriveDocument's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Id of the document body, not of the drive item it belongs to. The two are numbered separately. - drive_item_id (integer, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (markdown or null, required, detail only): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. Typing in the collaborative editor moves this, not the drive item's own `updated_at`. - links (DriveDocumentLinks, required) - self (url, required): The canonical URI of this record. - content_html (html or null, optional, on request): The same document as sanitised HTML, preserving the text colours, highlights, alignment and @mentions Markdown cannot carry. Mention spans round-trip unchanged; constructing a new one is out of scope for v2.0. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-documents/1' \ --data-urlencode 'include=content_html' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/drive-documents/{drive_document} Write a document body - MCP id: drive-documents.update - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Replaces or extends a document body. This is the one route in the API that writes one: the drive item itself carries no body. `content` is GitHub-Flavored Markdown, an empty body is rejected and any key other than `content` and `mode` is a `422`. By default `mode` is `replace`: the Markdown you send becomes the whole document, which overwrites whatever somebody else typed since your last read and discards, permanently, the text colours, highlights, alignment and @mentions Markdown cannot express; read `?include=content_html` on the document first if it may contain them. With `mode: append` the Markdown is added after the existing body and nothing already there is touched, which is the safe way to add notes to a shared document. The write goes through the live collaboration session, so anyone with the document open sees it immediately; if the collaboration server refuses the content, or is unavailable while the document is under collaborative editing, the write is refused with a `422` on `/content` rather than silently lost. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no revision number and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. The declared permission is `project_drive::create` (editing is part of the create permission); the other three drive prefixes apply by the owning item's owner type, and the record rule is edit access on that item. Note that the owning item being archived does not block this write, even though the item itself then reads as `404` without `trashed=with`; restore the item first if you want plnnrly and the API to agree. ## Parameters - drive_document (integer, path): The DriveDocument's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - content (string ≤ 262144, required): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - mode (string, optional): Write-only, on update only: `replace` (the default) makes `content` the whole body; `append` adds it after the existing body and keeps everything already there, colours and @mentions included. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Id of the document body, not of the drive item it belongs to. The two are numbered separately. - drive_item_id (integer, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (markdown or null, required, detail only): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. Typing in the collaborative editor moves this, not the drive item's own `updated_at`. - links (DriveDocumentLinks, required) - self (url, required): The canonical URI of this record. - content_html (html or null, optional, on request): The same document as sanitised HTML, preserving the text colours, highlights, alignment and @mentions Markdown cannot carry. Mention spans round-trip unchanged; constructing a new one is out of scope for v2.0. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/drive-documents/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "content": "# Kick-off\n\nConfirmed for Monday 09:00.\n" }' ``` --- # DELETE /api/v2/drive-documents/{drive_document} Delete a document body - MCP id: drive-documents.destroy - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently discards a document body, leaving the drive item itself in place and bodyless; a new one can be created for it with `POST /api/v2/drive-documents`. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Archiving a document is a different operation and belongs to the item: DELETE `/api/v2/drive-items/{drive_item}`, which is reversible. Deleting an already-deleted body answers `404`. The todos derived from the body are removed afterwards. The declared permission is `project_drive::create` (the same permission as editing); the other three drive prefixes apply by the owning item's owner type, and the record rule is edit access on that item. ## Parameters - drive_document (integer, path): The DriveDocument's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/drive-documents/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/drive-folders List drive folders - MCP id: drive-folders.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1, `q` +2. - Cache-Control: private, no-store Returns the drive folders the caller may read: the tree plnnrly's file browser draws. Access is decided per folder (the permission for the owner type, the access inherited from the nearest ancestor that does not inherit, then any per-folder grant), and it applies before any filter you send, so this collection can be read across the whole organisation without leaking a folder the caller cannot open. Narrow it to one drive with the `owner_type`/`owner_id` pair, which must be sent together, and to one level with `filter[parent_id]`; `filter[parent_id][null]=true` is the drive root, and a bare id is that folder's direct children, which is how a client walks the tree. The default name sort is stable across the organisation; the `order` sort is only meaningful once you have narrowed to one drive and one level, because positions are scoped per (`owner_type`, `owner_id`, `parent_id`). A folder is hard-deleted, so there is no trashed parameter. `total` is omitted unless you pass `with_total=1`. The declared permission is `project_drive::access`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, a token covering any of the four is accepted, and the per-folder rule is what actually decides. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[created_by] (integer, optional): Author of the folder. Null on legacy rows. Operators: = in null. - filter[general_access] (string, optional): Baseline sharing tier, which applies only while `inherits_permissions` is `false`. Operators: = ne in. - filter[inherits_permissions] (boolean, optional): Whether the folder takes its permissions from its parent. - filter[owner_id] (integer, optional): Id of the record whose drive this is, within the `owner_type` given. Must be sent together with `owner_type`. For `organization` it is your own organisation's id. Operators: = in. - filter[owner_type] (string, optional): Which drive the folder lives in: `project`, `customer`, `organization` or `user`. Must be sent together with `owner_id`. - filter[parent_id] (integer, optional): Folder this one sits in. Pass `null` to list the folders at the drive root, or an id to list one level of the tree. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null, required): Folder this one sits in, or `null` at the drive root. - parent_folder (Ref or DriveFolderSummary or null, required): The folder this one sits in; absent for folders at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - order (integer, required, server-assigned): Sort position within the drive and level, as shown in the drive tree. Positions count per drive and level, so the sequence is only meaningful once the collection is narrowed to one drive and one level. It is not assigned on create, so a folder created through the API stays at 0 until it is positioned in plnnrly; reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - inherits_permissions (boolean, required): Whether this folder takes its permissions from its parent. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this folder, after the permission for the owner type, the access inherited from folders and any per-folder grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - drive_items_count (integer ≥ 0, required): Live drive items directly in this folder that the caller may see. Archived items are not counted, and neither are items in subfolders. - links (DriveFolderLinks, required) - self (url, required): The canonical URI of this record. - drive_items (url, required): Drive items directly in this folder. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-folders' \ --data-urlencode 'filter[owner_type]=project' \ --data-urlencode 'filter[owner_id]=1' \ --data-urlencode 'filter[parent_id][null]=true' \ --data-urlencode 'include=owner' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-folders Create a drive folder - MCP id: drive-folders.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a folder under a project, a customer, the organisation drive or the caller's own drive (plnnrly's create-folder and create-subfolder actions) and returns it with a `Location` header. The drive is named by the `owner_type`/`owner_id` pair, this being one of the few flat creates that accepts a type and id pair, and a record the caller cannot read is refused exactly as a nonexistent one is. For `owner_type=organization` there is no `owner_id` to send: it is always your own organisation. A `parent_id` must belong to the same owner and be a folder you can edit; omit it, or send `null`, to create at the drive root. The new folder inherits its parent's permissions, and its sort position stays 0 until it is placed in plnnrly. The declared permission is `project_drive::create`; the other three drive prefixes apply by owner type, and the record rule is what actually decides. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - owner_type (string, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - owner_id (integer, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null → DriveFolder.id, optional): Folder this one sits in, or `null` at the drive root. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null, required): Folder this one sits in, or `null` at the drive root. - parent_folder (Ref or DriveFolderSummary or null, required): The folder this one sits in; absent for folders at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - order (integer, required, server-assigned): Sort position within the drive and level, as shown in the drive tree. Positions count per drive and level, so the sequence is only meaningful once the collection is narrowed to one drive and one level. It is not assigned on create, so a folder created through the API stays at 0 until it is positioned in plnnrly; reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - inherits_permissions (boolean, required): Whether this folder takes its permissions from its parent. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this folder, after the permission for the owner type, the access inherited from folders and any per-folder grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - drive_items_count (integer ≥ 0, required): Live drive items directly in this folder that the caller may see. Archived items are not counted, and neither are items in subfolders. - links (DriveFolderLinks, required) - self (url, required): The canonical URI of this record. - drive_items (url, required): Drive items directly in this folder. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-folders' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "owner_type": "project", "owner_id": 1, "name": "Kick-off" }' ``` --- # GET /api/v2/drive-folders/{drive_folder} Read a drive folder - MCP id: drive-folders.show - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one drive folder in the detail view. `access` is the calling user's own effective access level on it. `owner` and `parent_folder` render as references until named in include; an organisation-owned folder's owner is always a bare reference. The folder's contents are two other reads: `data.links.drive_items` for its items, and `GET /api/v2/drive-folders?filter[parent_id]={id}` for its subfolders. A folder is hard-deleted, so there is no trashed parameter. The declared permission is `project_drive::access`; the other three drive prefixes apply by owner type, and the per-folder rule is what actually decides. ## Parameters - drive_folder (integer, path): The DriveFolder's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null, required): Folder this one sits in, or `null` at the drive root. - parent_folder (Ref or DriveFolderSummary or null, required): The folder this one sits in; absent for folders at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - order (integer, required, server-assigned): Sort position within the drive and level, as shown in the drive tree. Positions count per drive and level, so the sequence is only meaningful once the collection is narrowed to one drive and one level. It is not assigned on create, so a folder created through the API stays at 0 until it is positioned in plnnrly; reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - inherits_permissions (boolean, required): Whether this folder takes its permissions from its parent. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this folder, after the permission for the owner type, the access inherited from folders and any per-folder grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - drive_items_count (integer ≥ 0, required): Live drive items directly in this folder that the caller may see. Archived items are not counted, and neither are items in subfolders. - links (DriveFolderLinks, required) - self (url, required): The canonical URI of this record. - drive_items (url, required): Drive items directly in this folder. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-folders/1' \ --data-urlencode 'include=owner,parent_folder' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/drive-folders/{drive_folder} Update a drive folder - MCP id: drive-folders.update - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a drive folder: absent keys are untouched, an empty body is rejected. `name` and `parent_id` are the writable keys: a rename, and a move to another folder within the same drive, which is plnnrly's folder drag-and-drop minus the position. The new parent must belong to the same owner, be a folder you can edit, and be neither this folder nor one of its descendants; `null` moves the folder to the drive root. Moving a folder to a different drive is not possible in v2.0: neither half of the owner pair is accepted on a PATCH, and neither are order, `created_by`, `general_access` or `inherits_permissions`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. The declared permission is `project_drive::create` (editing is part of the create permission), and it is a superset of what is checked: the record rule needs the owner type's `{owner}_drive::access` plus edit access on the folder, which is exactly what plnnrly enforces. ## Parameters - drive_folder (integer, path): The DriveFolder's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Folder name as shown in the drive tree. - parent_id (integer or null → DriveFolder.id, optional): Folder this one sits in, or `null` at the drive root. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null, required): Folder this one sits in, or `null` at the drive root. - parent_folder (Ref or DriveFolderSummary or null, required): The folder this one sits in; absent for folders at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - order (integer, required, server-assigned): Sort position within the drive and level, as shown in the drive tree. Positions count per drive and level, so the sequence is only meaningful once the collection is narrowed to one drive and one level. It is not assigned on create, so a folder created through the API stays at 0 until it is positioned in plnnrly; reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - inherits_permissions (boolean, required): Whether this folder takes its permissions from its parent. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this folder, after the permission for the owner type, the access inherited from folders and any per-folder grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - drive_items_count (integer ≥ 0, required): Live drive items directly in this folder that the caller may see. Archived items are not counted, and neither are items in subfolders. - links (DriveFolderLinks, required) - self (url, required): The canonical URI of this record. - drive_items (url, required): Drive items directly in this folder. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/drive-folders/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Kick-off (archive)", "parent_id": null }' ``` --- # DELETE /api/v2/drive-folders/{drive_folder} Delete a drive folder - MCP id: drive-folders.destroy - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes an empty drive folder. A folder holding a live drive item or a subfolder answers `409` `has_children`: plnnrly deletes any folder and moves its contents up, and the API deliberately does not, so move or delete the contents first. A folder is hard-deleted, so there is no restore and `force` is not an accepted parameter. One consequence worth planning for: only live items count, so a folder holding nothing but archived items is deletable, and those archived items land at the drive root rather than in this folder's parent. The declared permission is `project_drive::create`, the same permission as editing and a superset of what the record rule checks: `{owner}_drive::access` plus edit access on the folder. ## Parameters - drive_folder (integer, path): The DriveFolder's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/drive-folders/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/drive-items List drive items - MCP id: drive-items.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1 (to-many +3), `q` +2. - Cache-Control: private, no-store Returns the drive items the caller may read: the rows plnnrly's file browser lists. Access is decided per item (the permission for the owner type, the access inherited from its folder, then any per-item grant), and it applies before any filter you send, so this collection can be read across the whole organisation without leaking an item the caller cannot open. Narrow it to one drive with the `owner_type`/`owner_id` pair, which must be sent together, and to one folder with `filter[folder_id]`; the default `order_column` sort is only meaningful once you have, because positions are scoped per (`owner_type`, `owner_id`, `folder_id`) and an organisation-wide sort by it interleaves every drive. Pass `trashed=with` or `trashed=only` to see archived items; that needs only this endpoint's permission, never the restore ability. `total` is omitted unless you pass `with_total=1`. The declared permission is `project_drive::access`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, and a token covering any of the four is accepted. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[archived_by] (integer, optional): The user who archived the item. Pair it with `trashed=only` for an archive view. Operators: = in null. - filter[content_record_id] (integer, optional): Id of the document body. Must be sent together with `content_record_type`. Operators: = in null. - filter[content_record_type] (string, optional): Type of body the item points at. `drive_document` is the only value. Must be sent together with `content_record_id`. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[created_by] (integer, optional): Author of the item. Operators: = in null. - filter[folder_id] (integer, optional): Folder the item sits in. Pass `null` to list the items at the drive root. Operators: = in null. - filter[general_access] (string, optional): Baseline sharing tier, which applies only while `inherits_permissions` is `false`. Operators: = ne in. - filter[inherits_permissions] (boolean, optional): Whether the item takes its permissions from its folder. - filter[kind] (string, optional): What the item is. Operators: = ne in. - filter[owner_id] (integer, optional): Id of the record whose drive this is, within the `owner_type` given. Must be sent together with `owner_type`. For `organization` it is your own organisation's id. Operators: = in. - filter[owner_type] (string, optional): Which drive the item lives in: `project`, `customer`, `organization` or `user`. Must be sent together with `owner_id`. - filter[source] (string, optional): Where the item came from. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[work_items.id] (integer, optional): The drive items linked to one work item, by its numeric id. A work item's `links.drive_items` points here: it lists every linked item you may open, where `include=drive_items` stops at 25. Using it also requires `project_management::manage_work_items`. - q (string, optional): Free-text search over `title`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-items' \ --data-urlencode 'filter[owner_type]=project' \ --data-urlencode 'filter[owner_id]=1' \ --data-urlencode 'filter[folder_id][null]=true' \ --data-urlencode 'include=owner,tags' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-items Create a drive item - MCP id: drive-items.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a bodyless document item under a project, a customer, the organisation drive or the caller's own drive, and returns it with a `Location` header. The drive is named by the `owner_type`/`owner_id` pair (one of the few flat creates that accepts a type and id pair), and a record the caller cannot read is refused exactly as a nonexistent one is. For `owner_type=organization` there is no `owner_id` to send: it is always your own organisation. Give the item a body afterwards by POSTing to `/api/v2/drive-documents`; this endpoint never accepts one, and it cannot upload a file. A `folder_id` must belong to the same owner and be a folder you can edit. The declared permission is `project_drive::create`; the other three drive prefixes apply by owner type, and the record rule is what actually decides. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - owner_type (string, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - owner_id (integer, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, optional): Free-text grouping label shown in the drive tree. - folder_id (integer or null → DriveFolder.id, optional): Folder this item sits in, or `null` at the drive root. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-items' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "owner_type": "project", "owner_id": 1, "title": "Kick-off notes" }' ``` --- # GET /api/v2/drive-items/{drive_item} Read a drive item - MCP id: drive-items.show - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one drive item in the detail view. `access` is the calling user's own effective access level on it. The body is not here: read `content_record_id` and `GET /api/v2/drive-documents/{drive_document}` for it. `owner`, `drive_folder` and `tags` render as references until named in include; an organisation-owned item's owner is always a bare reference. An archived item is `404` unless `trashed=with` is passed, and `trashed=only` 404s a live one. The declared permission is `project_drive::access`; the other three drive prefixes apply by owner type, and the per-item rule is what actually decides. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-items/1' \ --data-urlencode 'include=owner,drive_folder,tags' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/drive-items/{drive_item} Update a drive item - MCP id: drive-items.update - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a drive item: absent keys are untouched, an empty body is rejected. `title`, `group`, `folder_id` and `tag_ids` are the writable keys; `tag_ids` replaces the whole tag set. Moving an item to another folder within the same drive is `folder_id`: the folder must belong to the same owner and be one you can edit. Moving an item to a different drive is not possible in v2.0: neither half of the owner pair is accepted on a PATCH, so plnnrly's move-between-drives action has no API equivalent. The body is not writable here either (`PATCH /api/v2/drive-documents/{drive_document}` is the one route that writes a document body), and neither are kind, source, `order_column`, the `content_record` pair, `general_access` or `inherits_permissions`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. The declared permission is `project_drive::create` (editing is part of the create permission), and it is a superset of what is checked: the record rule needs the owner type's `{owner}_drive::access` plus edit access on the item, which is exactly what plnnrly enforces. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - title (string ≤ 255, optional): Item title. - group (string or null ≤ 255, optional): Free-text grouping label shown in the drive tree. - folder_id (integer or null → DriveFolder.id, optional): Folder this item sits in, or `null` at the drive root. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/drive-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "title": "Kick-off notes (final)", "tag_ids": [] }' ``` --- # DELETE /api/v2/drive-items/{drive_item} Archive a drive item - MCP id: drive-items.destroy - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Archives a drive item. This is plnnrly's Archive action, and it is reversible with `POST /api/v2/drive-items/{drive_item}/actions/restore`. It is a soft delete: the item keeps its body, its comments and its files, and who archived it is recorded in `archived_by`. There is no permanent delete through the API, so `force` is not an accepted parameter. Archiving an already-archived item answers `204` as well, so a retry after a timeout is safe. The declared permission is `project_drive::create`, the same permission as editing and a superset of what the record rule checks: `{owner}_drive::access` plus edit access on the item. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/drive-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-items/{drive_item}/actions/restore Restore an archived drive item - MCP id: drive-items.actions.restore - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the archive marker, like the restore action on the archived documents list in plnnrly. Restoring requires the owner type's `{owner}_drive::access` plus edit access on the archived item; the declared `project_drive::create` is a superset of that. Restoring an item that is not archived succeeds and returns an `already_in_target_state` warning. The restore finds an archived item without the trashed parameter and does not accept it. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-items/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/drive-items/{drive_item}/comments List a drive item's comments - MCP id: drive-items.comments.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1. - Cache-Control: private, no-store Returns the comment thread on one drive item (the same thread the drive document editor writes), newest first, keyset-paginated. There is no organisation-wide collection of comments, so this nested path is the only way to list them and `filter[commentable_type]` / `filter[commentable_id]` do not exist: the parent is the path segment. Access to the parent is decided per item (the permission for the owner type, the access inherited from its folder, then any per-item grant), and an archived drive item answers `404`, because comments are hard-deleted and `trashed` is therefore not an accepted parameter on this path. The declared permission is `project_drive::access`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, and a token covering any of the four is accepted. Pass `include=author` to expand each comment's author; the body is in both views. One comment is read, edited and deleted at its flat path `/comments/{comment}`, listed under Work items. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[author_id] (integer, optional): Id of the comment's author. Send together with `author_type`. - filter[author_type] (string, optional): Type of the comment's author. `user` is the only value in v2.0. Send together with `author_id`. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-items/1/comments' \ --data-urlencode 'include=author' \ --data-urlencode 'per_page=25' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-items/{drive_item}/comments Post a comment on a drive item - MCP id: drive-items.comments.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one comment on the drive item named in the path, authored as the authenticated user, and returns it in the detail view with a `Location` header pointing at its flat path `/comments/{comment}`, where it is read, edited and deleted. `body` is the only accepted key: the drive item comes from the path, so `commentable_type` and `commentable_id` in the body are a `422`, and the author is the caller, so `author_type` and `author_id` are never accepted. Commenting is a reader's right on a drive item (the same rule the document editor's comment box applies), so the item's access only has to grant view, not edit. Posting works exactly like posting in the editor, so mention notifications, thread events and the author's auto-subscription all fire. The body is rich text: mention spans read from an existing comment may be posted back verbatim and are preserved, but constructing a new mention is not supported in v2.0. An archived drive item is `404`. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - body (string ≤ 65535, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-items/1/comments' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "body": "

Section 3 needs the new pricing table.

" }' ``` --- # GET /api/v2/drive-items/{drive_item}/files List a drive item's files - MCP id: drive-items.files.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the files on one drive item, newest upload first, keyset-paginated: all three of its file collections (`attachments`, `content` for images pasted into the editor, and `file` for the single uploaded payload), so narrow with `filter[collection]=file` to get the payload alone. There is no organisation-wide collection of files, so this nested path is the only way to list them and `filter[model_type]` / `filter[model_id]` do not exist: the parent is the path segment. Access to the parent is decided per item (the permission for the owner type, the access inherited from its folder, then any per-item grant), and an archived drive item answers `404`. The declared permission is `project_drive::access`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, and a token covering any of the four is accepted. Listing additionally requires `project_management::manage_work_items` for the files themselves. The API never streams bytes: pass `include=url` for a signed download URL per file, and attach a new file with the two-step upload flow that starts at `POST drive-items/{drive_item}/uploads`. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[collection] (string, optional): Narrow to one media collection. A work item registers only `attachments`; a drive item registers `attachments`, `content` (inline editor images) and `file` (the single uploaded payload), so `filter[collection]=file` is how a client asks a drive item for its payload alone. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[collection][starts] (string, optional): Narrow to one media collection. A work item registers only `attachments`; a drive item registers `attachments`, `content` (inline editor images) and `file` (the single uploaded payload), so `filter[collection]=file` is how a client asks a drive item for its payload alone. One value, matched literally as a prefix: send commas and backslashes as they are, and `%` and `_` match themselves rather than acting as wildcards. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): File name as uploaded. - mime_type (string or null ≤ 255, required): IANA media type. - size (integer, required): Size in bytes. - disk (string ≤ 255, required): Name of the storage location the file is kept in. - collection (string ≤ 255, required): Media collection the file belongs to. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Upload timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - url (string or null, optional, on request): Signed download URL, requiring your bearer token. Request with `?include=url`. Null only where your organisation's file storage cannot sign URLs, in which case the response carries a `signed_url_unavailable` warning and downloads are not available through the API. - url_expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional, on request): When `url` stops working. Always `null` in v2.0: the signed route the URL points at does not expire. Declared so a future bounded-TTL download path is an additive change. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/drive-items/1/files' \ --data-urlencode 'filter[collection]=file' \ --data-urlencode 'include=url' \ --data-urlencode 'per_page=25' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/drive-items/{drive_item}/files Attach an uploaded file to a drive item - MCP id: drive-items.files.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Step 3 of 3. Turns bytes you have already uploaded into a file on the drive item named in the path, and returns it in the detail view. `upload_id` is the only key this accepts: create a ticket with `POST drive-items/{drive_item}/uploads`, PUT the raw bytes to its `upload_url`, then send its id here. Everything about the file (its name, size, media type, collection and storage location) is read off the ticket and off the bytes that actually arrived, so `file_name`, `mime_type`, `byte_size`, collection and disk are all a `422`. The file lands in the document's `attachments` collection; `content` belongs to the editor and `file` is the document's own payload, so neither can be written here. The ticket must have been created for this drive item, by this API token, and must not have expired or been registered already. The declared permission is `project_drive::create`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, and the record rule requires edit access: read access to a document is not enough to attach to it, which is deliberately stricter than plnnrly. Attaching additionally requires `project_management::manage_work_items` for the file itself. An archived document is `404`. `Idempotency-Key` is required: a lost 201 retried without one would report a failure for a create that succeeded. There is no `Location` header (a file has no item URL of its own), and no PATCH or DELETE on a file; remove one in plnnrly. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, required): A caller-chosen key that makes this POST safe to retry. Required on this operation: omitting it is 400 idempotency-key-required. ## Request body - upload_id (integer → Upload.id, required): The id of the upload ticket whose bytes become this file. Create one with `POST work-items/{work_item}/uploads` or `POST drive-items/{drive_item}/uploads`, PUT the bytes to its `upload_url`, then send its id here. Write-only: it is never rendered on a media row, and it is the only key either attachment store accepts. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): File name as uploaded. - mime_type (string or null ≤ 255, required): IANA media type. - size (integer, required): Size in bytes. - disk (string ≤ 255, required): Name of the storage location the file is kept in. - collection (string ≤ 255, required): Media collection the file belongs to. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Upload timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - url (string or null, optional, on request): Signed download URL, requiring your bearer token. Request with `?include=url`. Null only where your organisation's file storage cannot sign URLs, in which case the response carries a `signed_url_unavailable` warning and downloads are not available through the API. - url_expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional, on request): When `url` stops working. Always `null` in v2.0: the signed route the URL points at does not expire. Declared so a future bounded-TTL download path is an additive change. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-items/1/files' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "upload_id": 1 }' ``` --- # POST /api/v2/drive-items/{drive_item}/uploads Start a file upload on a drive item - MCP id: drive-items.uploads.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Step 1 of 3. Returns an upload ticket whose `upload_url` you PUT the raw bytes to, after which you POST the ticket's id as `upload_id` to `drive-items/{drive_item}/files` to turn those bytes into a file. The bytes never travel through this API: `/api/v2` accepts `application/json` and nothing else, so the upload target is a separate signed route. The file always lands in the document's `attachments` collection (`content` belongs to the editor and `file` is the document's own single payload, so neither is writable here), and the collection, the storage location and the ticket lifetime are all chosen by the server. The declared size is not a bound: the server counts what arrives and refuses anything over 10 MB. The declared permission is `project_drive::create`; `customer_drive`, `organization_drive` and `user_drive` apply by owner type, and the record rule requires edit access: read access to a document is not enough to attach to it, which is deliberately stricter than plnnrly. An archived document is `404`. The ticket is yours alone, is single-use, expires in 30 minutes, and can be deleted with `DELETE uploads/{upload}` while unregistered. ## Parameters - drive_item (integer, path): The DriveItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer 1 to 10485760, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null ≤ 64, optional): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. Send this as `upload_id` when you register the bytes. - work_item_id (integer or null, required): The work item this ticket attaches to, or `null` when it targets a drive item. - drive_item_id (integer or null, required): The drive item this ticket attaches to, or `null` when it targets a work item. - collection (string ≤ 255, required): The media collection the file will land in. Always `attachments`: it is chosen by the route, and no request field can change it. - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null, required): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. - received_byte_size (integer or null, required): The number of bytes that actually arrived, measured by the server. Null until the upload lands. - received_checksum_sha256 (string or null, required): The SHA-256 the server computed over the bytes it received. Null until the upload lands. - mime_type (string or null ≤ 255, required): The media type detected from the received bytes. Detected, never declared: the API accepts no content type when creating the ticket and ignores the `Content-Type` of the upload itself. - status (string, required): Where the ticket is in its lifecycle. - upload_url (string or null, required): Where to PUT the file. An opaque absolute URL: send the raw file as the body of a PUT whose only header is your Authorization bearer token. No `Content-Type` is read and the body is stored exactly as sent, so a form envelope would become part of the file. Present only while status is `pending`. - expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): The deadline for the PUT. After it the ticket is dead and a new one must be created. - register_by (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): The deadline for the registration, set when the bytes land and measured from that moment, so a slow upload does not eat into the time you have to register. Null until the upload lands. - uploaded_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes arrived. Null until they do. - committed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes became a file. Null until they do. - media_id (integer or null, required): The id of the file this ticket became. Null until it is registered, and set exactly once. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the ticket was created. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (UploadLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/drive-items/1/uploads' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "file_name": "quarterly-report.pdf", "byte_size": 184320 }' ``` --- # GET /api/v2/events List events - MCP id: events.index - Scope: api:read:events - Permission: events::manage_self - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1, `q` +2. - Cache-Control: private, no-store Returns agenda events, latest `start` first. A caller sees only their own events unless they hold `events::manage_for_others`, and `filter[user_id]` defaults to the calling user even then; pass `filter[user_id]=me,` to widen it, which requires that permission. Fetch one window of the calendar with `filter[start][gte]` and `filter[start][lte]`. A recurring master is one row: its occurrences are generated from `recurrence_frequency`, `recurrence_interval`, `selected_weekdays` and `recurrence_exceptions` and are not returned individually, except where plnnrly has separated one into a row of its own (those rows carry `parent_event_id`). `meta.page.total` is only computed when you ask for it with `with_total=true`. Deleted events are excluded unless you pass `trashed=with` or `trashed=only`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[end] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the event ends. Operators: = gte lte. - filter[is_all_day] (boolean, optional): Whether the event occupies whole days rather than a time range. - filter[is_recurring] (boolean, optional): Whether the event is the master of a recurring series. - filter[occurrence_date] (date (YYYY-MM-DD), optional): The series date a separated occurrence replaces. Operators: = gte lte null. - filter[parent_event_id] (integer, optional): Master event of a separated occurrence. `null` selects everything that is not a separated occurrence. Operators: = in null. - filter[recurrence_end] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last moment a series may generate an occurrence. `null` selects the open-ended series. Operators: = gte lte null. - filter[recurrence_frequency] (string, optional): The unit a recurring series repeats in. Operators: = ne in. - filter[recurrence_pattern] (string, optional): How a monthly or yearly series positions its occurrence within the month. Operators: = ne in. - filter[recurrence_start] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): First moment a series may generate an occurrence. Operators: = gte lte null. - filter[start] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the event starts. Combine gte and lte to fetch one window of the agenda. Operators: = gte lte. - filter[task_id] (integer, optional): Kanban task the event is planned against. `null` selects the events with no task, which are the ones no time can be logged on. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose agenda the event is on. Accepts the literal `me`, and defaults to the calling user when omitted. Naming another user requires the `events::manage_for_others` permission. Operators: = in. - q (string, optional): Free-text search over `title`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/events' \ --data-urlencode 'filter[start][gte]=2026-03-02T00:00:00Z' \ --data-urlencode 'filter[start][lte]=2026-03-08T23:59:59Z' \ --data-urlencode 'sort=start' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/events Create an event - MCP id: events.store - Scope: api:write:events - Permission: events::manage_self - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Puts an event on the authenticated user's agenda and returns it with a `Location` header. The event is always the caller's own: `user_id` is not a body key, and there is no way to book an event for a colleague through the API, even with `events::manage_for_others`. `start`, `end`, `recurrence_start` and `recurrence_end` are stored exactly as sent: they are RFC 3339 UTC instants and no timezone conversion is applied, unlike the form in plnnrly, which collects local wall-clock time. Send `is_recurring: true` with a frequency and an interval to create a series master; `recurrence_pattern` only affects a monthly or yearly frequency and `selected_weekdays` only a weekly one; a value that does not apply is stored and ignored. Linking a `task_id` requires the task to be one the caller may see, and only an event with a task can have time logged on it. `parent_event_id` and `occurrence_date` are server-assigned and cannot be sent: separating a single occurrence out of a series is done in plnnrly and has no API equivalent in this version. Creating an event notifies anyone `@`-mentioned in the description and pushes the new event to open agenda tabs. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - title (string ≤ 255, required): Event title, as shown on the calendar tile. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. Must be after -5 years. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. Must be on or after start. Must be before +5 years. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - description (string or null, optional): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - task_id (integer or null → Task.id, optional): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - is_recurring (boolean, optional): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null ≥ 1, optional): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, optional): The unit the series repeats in. - recurrence_pattern (string or null, optional): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): Last moment the series may generate an occurrence; `null` for an open-ended series. Must be on or after recurrence_start. - selected_weekdays (array of string or null, optional): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, optional): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/events' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "title": "Design review", "start": "2026-03-03T09:00:00Z", "end": "2026-03-03T10:30:00Z", "is_all_day": false }' ``` --- # GET /api/v2/events/{event} Read an event - MCP id: events.show - Scope: api:read:events - Permission: events::manage_self - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one event in the detail view, which is where `description`, `selected_weekdays` and `recurrence_exceptions` live; the summary view a list returns omits those three. `user` renders as a `{"id": N}` reference; `?include=user` expands it, and that expansion additionally requires `users::read` and a token scope covering the users prefix. A deleted event is `404` unless you pass `trashed=with`. ## Parameters - event (integer, path): The Event's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/events/1' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/events/{event} Update an event - MCP id: events.update - Scope: api:write:events - Permission: events::manage_self - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates an event: absent keys are untouched, an empty body is rejected. ⚠ On a recurring master this edits the whole series (every occurrence moves), where plnnrly would have asked "this occurrence / this and following / all". There is no per-occurrence write surface in this version: `parent_event_id` and `occurrence_date` are server-assigned. `start`, `end` and the two recurrence instants are stored exactly as sent, with no timezone conversion. Send `task_id: null` to unlink the task, which also stops time being logged on the event; setting a task additionally requires that the task be selectable by the event's owner, not merely by you, so re-pointing a colleague's event at your own task is refused. Note: `end` is only checked against `start` when both are sent in the same request, so sending `end` alone can leave it before the stored `start`; an event in that state measures zero planned minutes everywhere it is read. Changing the `@`-mentions in the description notifies the people newly mentioned. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - event (integer, path): The Event's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - title (string ≤ 255, optional): Event title, as shown on the calendar tile. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the event starts. RFC 3339 UTC. Must be after -5 years. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the event ends. RFC 3339 UTC. Must be on or after start. Must be before +5 years. - is_all_day (boolean, optional): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - description (string or null, optional): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - task_id (integer or null → Task.id, optional): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - is_recurring (boolean, optional): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null ≥ 1, optional): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, optional): The unit the series repeats in. - recurrence_pattern (string or null, optional): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional): Last moment the series may generate an occurrence; `null` for an open-ended series. Must be on or after recurrence_start. - selected_weekdays (array of string or null, optional): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, optional): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: The event is your own and you hold `events::manage_for_others` but not `events::manage_self`, or the `task_id` you send is a task the event's owner cannot select. Another user's event answers `404` instead when you do not hold `events::manage_for_others`, because you cannot see it. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/events/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "start": "2026-03-03T10:00:00Z", "end": "2026-03-03T11:30:00Z" }' ``` --- # DELETE /api/v2/events/{event} Delete an event - MCP id: events.destroy - Scope: api:destroy:events - Permission: events::manage_self - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes an event; `POST events/{event}/actions/restore` reverses it. ⚠ On a recurring master this removes the whole series (every occurrence disappears), where plnnrly would have asked "this occurrence / this and following / all". There is no `force` parameter: this resource is not force-deletable, so `?force=true` is a `400` `unsupported-parameter`. Deleting an event that is already deleted answers `204` as well, so a retry is safe. Any time already logged against the event's task is unaffected. Known gap: unlike plnnrly, an API delete does not push the removal to open agenda tabs, so a colleague with the calendar open may keep seeing the event until they reload. ## Parameters - event (integer, path): The Event's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: The event is your own and you hold `events::manage_for_others` but not `events::manage_self`. Another user's event answers `404` instead when you do not hold `events::manage_for_others`, because you cannot see it. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/events/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/events/{event}/actions/restore Restore a deleted event - MCP id: events.actions.restore - Scope: api:write:events - Permission: events::manage_self - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the event in the detail view, putting it back on its owner's agenda. Restoring somebody else's event additionally requires `events::manage_for_others`, on top of `events::manage_self`. Restoring an event that is not deleted succeeds and returns an `already_in_target_state` warning, so a retry is safe. On a recurring master this brings the whole series back. The restore reaches a deleted event without the trashed parameter and does not accept it. ## Parameters - event (integer, path): The Event's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/events/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/free-days List free days - MCP id: free-days.index - Scope: api:read:free_days - Permission: free_days::manage - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the organisation's free days (the calendar dates on which it is closed, whether created from a recurring holiday or added by hand), newest first. Free days are excluded from the working days a furlough request consumes. There is no `year` parameter: filter a year as `filter[date][gte]=2026-01-01&filter[date][lte]=2026-12-31`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[date] (date (YYYY-MM-DD), optional): Calendar date the organisation is closed. Operators: = gte lte. - filter[holiday_id] (integer, optional): Recurring holiday the free day was instantiated from. `null` selects the manually added days. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null, required): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. - holiday (Ref or HolidaySummary or null, required): The recurring holiday this free day instantiates; absent for a manually added day. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FreeDayLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/free-days' \ --data-urlencode 'filter[date][gte]=2026-01-01' \ --data-urlencode 'filter[date][lte]=2026-12-31' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/free-days Create a free day - MCP id: free-days.store - Scope: api:write:free_days - Permission: free_days::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one free day and returns it with a `Location` header. `description` is required and is not derived from `holiday_id`: for a holiday-derived day plnnrly copies the holiday name into it at creation time and the two drift independently, so send the label you want. Nothing enforces one free day per date: plnnrly's year generation skips an already-used date, but this endpoint does not, and a duplicate date is a plain `201`. To generate a year of free days from the recurring holidays, call this endpoint once per holiday. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null → Holiday.id, optional): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null, required): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. - holiday (Ref or HolidaySummary or null, required): The recurring holiday this free day instantiates; absent for a manually added day. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FreeDayLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/free-days' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "description": "Office closed for the annual outing", "date": "2026-07-03" }' ``` --- # GET /api/v2/free-days/{free_day} Read a free day - MCP id: free-days.show - Scope: api:read:free_days - Permission: free_days::manage - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one free day. The summary and detail views are identical on this resource: every field is small enough for a list row. `holiday` renders as an `{"id": N}` reference; `?include=holiday` expands it, and that expansion additionally requires `holidays::manage` and a token scope covering the holidays prefix. ## Parameters - free_day (integer, path): The FreeDay's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null, required): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. - holiday (Ref or HolidaySummary or null, required): The recurring holiday this free day instantiates; absent for a manually added day. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FreeDayLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/free-days/1' \ --data-urlencode 'include=holiday' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/free-days/{free_day} Update a free day - MCP id: free-days.update - Scope: api:write:free_days - Permission: free_days::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a free day: absent keys are untouched, an empty body is rejected. Send `holiday_id: null` to detach a free day from its holiday and keep it as a manual day. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - free_day (integer, path): The FreeDay's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - description (string ≤ 255, optional): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), optional): Calendar date the organisation is closed. - holiday_id (integer or null → Holiday.id, optional): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null, required): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. - holiday (Ref or HolidaySummary or null, required): The recurring holiday this free day instantiates; absent for a manually added day. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FreeDayLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/free-days/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "holiday_id": null, "description": "Office closed (moved)" }' ``` --- # DELETE /api/v2/free-days/{free_day} Delete a free day - MCP id: free-days.destroy - Scope: api:destroy:free_days - Permission: free_days::manage - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a free day. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Nothing blocks the delete: a free day carries no dependants, and deleting one simply puts the date back into the working days a furlough request consumes. ## Parameters - free_day (integer, path): The FreeDay's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/free-days/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/furlough-balance-mutations List furlough balance mutations - MCP id: furlough-balance-mutations.index - Scope: api:read:furlough - Permission: none, the record's own rules still apply - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1, `q` +2. - Cache-Control: private, no-store Returns furlough balance mutations, an employee's own leave history by default, newest first. This one endpoint covers three lists in plnnrly: a year of one employee's history is `filter[user_id]=me&filter[year]=2026`, the manager's approval queue is `filter[is_approved][null]=true`, and a per-employee drill-down is `filter[user_id]=N`. The furlough prefix has no read permission, so what you see depends on your permissions alone: a caller sees only their own mutations unless they hold `furlough::view_all` or `furlough::manage`, and pointing `filter[user_id]` at another employee requires one of those two permissions. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[acceptor_id] (integer, optional): The user who approved the mutation. `null` selects the mutations no one has approved: the pending ones and the denied ones alike. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[end] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): End of the period the mutation covers, in UTC. Operators: = gte lte. - filter[is_approved] (boolean, optional): Approval state. `true` selects the approved mutations, `false` the denied ones, and `filter[is_approved][null]=true` the ones still pending approval, which is the approval queue. Operators: = null. - filter[start] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Start of the period the mutation covers, in UTC. Operators: = gte lte. - filter[type] (string, optional): Which leave balance the mutation moves. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose mutations to return. Accepts the literal `me`. Naming another user requires the `furlough::view_all` or `furlough::manage` permission, the same two that show the whole organisation's mutations. Operators: = in. - filter[year] (integer, optional): The balance year the mutation counts against. Every balance sum counts by this year, so it is the right way to narrow a balance history to one year, rather than a range over `start`. Operators: = in. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The employee this mutation belongs to. Always the authenticated user on create and not writable. - acceptor_id (integer or null, required, server-assigned): The user who approved this mutation, set by the server when the mutation resolves to approved. Null while it is pending, and left `null` by a denial. Resolve the name through `GET /api/v2/users/{user}`. - year (integer, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, required): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. - description (string, required, multiline): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, required): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. - user (Ref or UserSummary, required): The employee this mutation belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FurloughBalanceMutationLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/furlough-balance-mutations' \ --data-urlencode 'filter[is_approved][null]=true' \ --data-urlencode 'sort=start' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/furlough-balance-mutations Create a furlough balance mutation - MCP id: furlough-balance-mutations.store - Scope: api:write:furlough - Permission: furlough::create_for_yourself - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one mutation for the authenticated user and returns it with a `Location` header. `user_id` is always the caller: this resource carries no on-behalf-of field, so a manager cannot post a mutation for another employee through the API even though the balance screen in plnnrly can. A negative `minutes` is a leave request over the period; a positive `minutes` grants balance and requires `furlough::manage`. `is_approved` is not accepted: the mutation is always the caller's own, so every mutation is created pending and is approved or denied by someone else through PATCH. A special-leave request that would take the special-leave balance for the year below zero is rejected with `422`. Creating a pending mutation notifies every `furlough::manage` holder by e-mail. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - year (integer 1970 to 2200, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. A positive value grants balance and requires the `furlough::manage` permission; a caller with only `furlough::create_for_yourself` may write a negative value (a leave request) or zero. On a `special` mutation, the resulting special-leave balance for the year may not go negative. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. Must be on or after start. - description (string, required): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - type (string, optional): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The employee this mutation belongs to. Always the authenticated user on create and not writable. - acceptor_id (integer or null, required, server-assigned): The user who approved this mutation, set by the server when the mutation resolves to approved. Null while it is pending, and left `null` by a denial. Resolve the name through `GET /api/v2/users/{user}`. - year (integer, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, required): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. - description (string, required, multiline): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, required): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. - user (Ref or UserSummary, required): The employee this mutation belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FurloughBalanceMutationLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/furlough-balance-mutations' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "year": 2026, "minutes": -960, "start": "2026-07-06T00:00:00Z", "end": "2026-07-07T23:59:59Z", "description": "Long weekend" }' ``` --- # GET /api/v2/furlough-balance-mutations/{furlough_balance_mutation} Read a furlough balance mutation - MCP id: furlough-balance-mutations.show - Scope: api:read:furlough - Permission: none, the record's own rules still apply - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one mutation. The summary and detail views are identical on this resource: every field is small enough for a list row. `user` renders as an `{"id": N}` reference; `?include=user` expands it, and that expansion additionally requires `users::read` and a token scope covering the users prefix. `acceptor_id` has no relation to expand: look the approver up with `GET /api/v2/users/{user}`. As on the list operation, the furlough prefix has no read permission, so another employee's mutation answers `404`, never `403`, unless the caller holds `furlough::view_all` or `furlough::manage`. ## Parameters - furlough_balance_mutation (integer, path): The FurloughBalanceMutation's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The employee this mutation belongs to. Always the authenticated user on create and not writable. - acceptor_id (integer or null, required, server-assigned): The user who approved this mutation, set by the server when the mutation resolves to approved. Null while it is pending, and left `null` by a denial. Resolve the name through `GET /api/v2/users/{user}`. - year (integer, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, required): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. - description (string, required, multiline): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, required): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. - user (Ref or UserSummary, required): The employee this mutation belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FurloughBalanceMutationLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/furlough-balance-mutations/1' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/furlough-balance-mutations/{furlough_balance_mutation} Update a furlough balance mutation - MCP id: furlough-balance-mutations.update - Scope: api:write:furlough - Permission: furlough::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a mutation: absent keys are untouched, an empty body is rejected, and an unknown key is a `422`. This is the API's only approval surface: `{"is_approved": true}` approves, `{"is_approved": false}` denies, and either needs `furlough::manage`, which is also this operation's own permission, so a plain employee cannot edit even their own pending request here although plnnrly lets them while it is still in the future. A manager deciding on their own mutation answers `403` `forbidden-by-policy`. ⚠ An approval through this endpoint is a plain field write: unlike the approval screen in plnnrly it does not split a request that spans two balance years into one mutation per year, and it sends the employee no e-mail. ⚠ `start` and `end` move as a pair (send both or neither), and neither rewrites `year`, so a period moved into another year keeps counting against the old balance year until `year` is sent too. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - furlough_balance_mutation (integer, path): The FurloughBalanceMutation's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - year (integer 1970 to 2200, optional): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, optional): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, optional): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. A positive value grants balance and requires the `furlough::manage` permission; a caller with only `furlough::create_for_yourself` may write a negative value (a leave request) or zero. On a `special` mutation, the resulting special-leave balance for the year may not go negative. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. Required when end is present. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. Required when start is present. Must be on or after start. - description (string, optional): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, optional): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The employee this mutation belongs to. Always the authenticated user on create and not writable. - acceptor_id (integer or null, required, server-assigned): The user who approved this mutation, set by the server when the mutation resolves to approved. Null while it is pending, and left `null` by a denial. Resolve the name through `GET /api/v2/users/{user}`. - year (integer, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, required): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. - description (string, required, multiline): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, required): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. - user (Ref or UserSummary, required): The employee this mutation belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FurloughBalanceMutationLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/furlough-balance-mutations/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "is_approved": true }' ``` --- # DELETE /api/v2/furlough-balance-mutations/{furlough_balance_mutation} Delete a furlough balance mutation - MCP id: furlough-balance-mutations.destroy - Scope: api:destroy:furlough - Permission: furlough::manage - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a mutation. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Nothing blocks the delete; the minutes simply stop counting against the balance year. This is plnnrly's withdraw and delete actions rolled into one, but it requires `furlough::manage`, so a plain employee cannot withdraw their own pending request through the API even though the history page in plnnrly lets them. ## Parameters - furlough_balance_mutation (integer, path): The FurloughBalanceMutation's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/furlough-balance-mutations/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/holidays List holidays - MCP id: holidays.index - Scope: api:read:holidays - Permission: holidays::manage - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's holiday templates, in the order plnnrly lists them: `sort_order` ascending. Search by name with q. A holiday carries no calendar date, so to find out when a holiday actually falls, follow `links.free_days`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null, required): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null, required): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null, required): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. - sort_order (integer, required, server-assigned): Position in the organisation's holiday list, ascending. Set by dragging holidays into order in plnnrly; the API exposes no way to reorder holidays. - free_days_count (integer ≥ 0, required): Number of free days generated from this holiday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (HolidayLinks, required) - self (url, required): The canonical URI of this record. - free_days (url, required): Free days generated from this holiday. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/holidays' \ --data-urlencode 'q=Kerst' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/holidays Create a holiday - MCP id: holidays.store - Scope: api:write:holidays - Permission: holidays::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a holiday template and returns it in the detail view with a `Location` header. Leave `default_month` and `default_day` out for a holiday that moves from year to year; the actual dates are recorded as free days afterwards. Creating a holiday whose name already exists succeeds: holiday names are not unique. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null 1 to 12, optional): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null 1 to 31, optional): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null 1 to 255, optional): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null, required): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null, required): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null, required): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. - sort_order (integer, required, server-assigned): Position in the organisation's holiday list, ascending. Set by dragging holidays into order in plnnrly; the API exposes no way to reorder holidays. - free_days_count (integer ≥ 0, required): Number of free days generated from this holiday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (HolidayLinks, required) - self (url, required): The canonical URI of this record. - free_days (url, required): Free days generated from this holiday. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/holidays' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Nieuwjaarsdag", "default_month": 1, "default_day": 1, "recurrence_interval": 1 }' ``` --- # GET /api/v2/holidays/{holiday} Read a holiday - MCP id: holidays.show - Scope: api:read:holidays - Permission: holidays::manage - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one holiday in the detail view, including how many free days have been generated from it. ## Parameters - holiday (integer, path): The Holiday's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null, required): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null, required): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null, required): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. - sort_order (integer, required, server-assigned): Position in the organisation's holiday list, ascending. Set by dragging holidays into order in plnnrly; the API exposes no way to reorder holidays. - free_days_count (integer ≥ 0, required): Number of free days generated from this holiday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (HolidayLinks, required) - self (url, required): The canonical URI of this record. - free_days (url, required): Free days generated from this holiday. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/holidays/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/holidays/{holiday} Update a holiday - MCP id: holidays.update - Scope: api:write:holidays - Permission: holidays::manage - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a holiday: absent keys are untouched, and an explicit `null` clears `default_month`, `default_day` or `recurrence_interval`. `sort_order` is not writable: holidays are reordered in plnnrly. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - holiday (integer, path): The Holiday's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null 1 to 12, optional): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null 1 to 31, optional): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null 1 to 255, optional): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null, required): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null, required): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null, required): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. - sort_order (integer, required, server-assigned): Position in the organisation's holiday list, ascending. Set by dragging holidays into order in plnnrly; the API exposes no way to reorder holidays. - free_days_count (integer ≥ 0, required): Number of free days generated from this holiday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (HolidayLinks, required) - self (url, required): The canonical URI of this record. - free_days (url, required): Free days generated from this holiday. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/holidays/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "default_month": null, "default_day": null }' ``` --- # DELETE /api/v2/holidays/{holiday} Delete a holiday - MCP id: holidays.destroy - Scope: api:destroy:holidays - Permission: holidays::manage - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a holiday template; holidays do not soft-delete and there is no restore. The free days generated from it are not deleted: their `holiday_id` is set to `null`, so they survive as unlabelled days off. Detach or delete those free days first if you do not want them left behind. ## Parameters - holiday (integer, path): The Holiday's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/holidays/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/invoices List invoices - MCP id: invoices.index - Scope: api:read:invoices - Permission: invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the legacy per-project invoices, newest invoice date first. Every invoice in the organisation is visible to any caller holding `invoices::read`; there is no per-record rule. Scope to one project with `filter[project_id]`, to a period with `filter[date][gte]` and `filter[date][lte]`, and search with `q=` over the description. Defaults to the summary view; `view=detail` carries the same fields on this resource (none of them is detail-only) and costs three read units instead of one. Line items are not published, so no total appears here. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[date] (date (YYYY-MM-DD), optional): Invoice date. Combine gte and lte to select a period, e.g. one quarter. Operators: = gte lte. - filter[project_id] (integer, optional): Project the invoice was raised for. This is how you list one project's invoices: there is no nested path for them. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Employee the invoice is attributed to. `null` selects the invoices whose recorder has since been deleted. Operators: = in null. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Project this invoice was raised for. - project (Ref or ProjectSummary, required): The project this invoice was raised for. - user_id (integer or null, required, server-assigned): Employee the invoice is attributed to. Assigned from the authenticated user on create and never writable; `null` once that user is deleted. - user (Ref or UserSummary or null, required): The employee this invoice is attributed to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - description (string, required, multiline): What was invoiced, as free text. This is the field `?q=` searches. - date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - hourly_rate (decimal (2 dp) ≥ 0, required): Hourly rate this invoice was billed at, as a decimal string with two places. Never rounded or rescaled. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a legacy invoice has no `version` field. - links (InvoiceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/invoices' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'filter[date][gte]=2026-01-01' \ --data-urlencode 'filter[date][lte]=2026-03-31' \ --data-urlencode 'sort=-date' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/invoices Create an invoice - MCP id: invoices.store - Scope: api:write:invoices - Permission: invoices::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a legacy invoice against one project and returns it in the detail view with a `Location` header. All four body fields are required: none has a default. The invoice is attributed to the authenticated user; `user_id` is not writable and there is no on-behalf-of field, so an API-created invoice reads differently from the historical invoices, which carry the project manager instead. `project_id` is validated across into the projects prefix, so creating an invoice additionally requires the `projects::read` permission and a token scope covering the projects prefix; without either, `project_id` is refused as if it did not exist. Creating one bumps the project's `updated_at` and lands in the project's activity timeline. It has no line items and no way to add them (invoice rows are not part of this API), so the project's billed figure is unaffected. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): Project this invoice was raised for. - description (string, required): What was invoiced, as free text. This is the field `?q=` searches. - date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - hourly_rate (decimal (2 dp) 0 to 999999.99, required): Hourly rate this invoice was billed at, as a decimal string with two places. Never rounded or rescaled. At least 0. At most 999999.99. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - project_id (integer, required): Project this invoice was raised for. - project (Ref or ProjectSummary, required): The project this invoice was raised for. - user_id (integer or null, required, server-assigned): Employee the invoice is attributed to. Assigned from the authenticated user on create and never writable; `null` once that user is deleted. - user (Ref or UserSummary or null, required): The employee this invoice is attributed to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - description (string, required, multiline): What was invoiced, as free text. This is the field `?q=` searches. - date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - hourly_rate (decimal (2 dp) ≥ 0, required): Hourly rate this invoice was billed at, as a decimal string with two places. Never rounded or rescaled. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a legacy invoice has no `version` field. - links (InvoiceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/invoices' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "description": "Sprint 14: portal rebuild", "date": "2026-03-31", "hourly_rate": "85.00" }' ``` --- # GET /api/v2/invoices/{invoice} Read an invoice - MCP id: invoices.show - Scope: api:read:invoices - Permission: invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one invoice in the detail view. Invoices are hard-deleted and are removed with their project, so a deleted invoice is gone and answers `404`. The path takes the numeric id only: this resource has no alternate key, unlike a sales invoice, which can also be addressed by `invoice_number`. ## Parameters - invoice (integer, path): The Invoice's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Project this invoice was raised for. - project (Ref or ProjectSummary, required): The project this invoice was raised for. - user_id (integer or null, required, server-assigned): Employee the invoice is attributed to. Assigned from the authenticated user on create and never writable; `null` once that user is deleted. - user (Ref or UserSummary or null, required): The employee this invoice is attributed to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - description (string, required, multiline): What was invoiced, as free text. This is the field `?q=` searches. - date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - hourly_rate (decimal (2 dp) ≥ 0, required): Hourly rate this invoice was billed at, as a decimal string with two places. Never rounded or rescaled. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a legacy invoice has no `version` field. - links (InvoiceLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/invoices/1' \ --data-urlencode 'include=project,user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/labels List labels - MCP id: labels.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's labels (the per-project tags work items are marked with) in each project's display order, with the id as the tiebreaker. The list covers the whole organisation and interleaves projects, because `order` is unique within a project only; narrow it with `filter[project_id]`. Listing additionally requires `project_management::manage_work_items`. Available only while the Sprints feature is enabled. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[project_id] (integer, optional): Narrow the list to one project's labels, or to several with `in`. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[work_items.id] (integer, optional): The labels on one work item, by its numeric id. A work item's `links.labels` points here: it lists every label on the item, where `include=labels` stops at 25. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, required): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the next position in that project's label order, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (LabelLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/labels' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'sort=order' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/labels Create a label - MCP id: labels.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a label on a project and returns it with a `Location` header. `color` is optional: omit it and the server picks the first palette colour the project does not already use. `order` is not accepted: the new label is placed last within its project and is reordered only by dragging it in plnnrly. Attach the label to work items from the work-item side, with `label_ids`. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, optional): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, optional): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - project_id (integer, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, required): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the next position in that project's label order, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (LabelLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/labels' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "name": "Blocked" }' ``` --- # GET /api/v2/labels/{label} Read a label - MCP id: labels.show - Scope: api:read:project_management - Permission: project_management::view_backlog - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one label. The summary and detail views are identical on this resource: every field is small enough for a list row, and nothing is includable, so `include` is rejected; follow `project_id` for the project. Reading additionally requires `project_management::manage_work_items`. ## Parameters - label (integer, path): The Label's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, required): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the next position in that project's label order, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (LabelLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/labels/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/labels/{label} Update a label - MCP id: labels.update - Scope: api:write:project_management - Permission: project_management::manage_work_items - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a label's name, colour or icon: absent keys are untouched, an empty body is rejected. `project_id` and `order` are not accepted: a label cannot be moved between projects, because its work-item attachments and its position are both scoped to the project it was created on. Renaming or recolouring a label notifies every work item carrying it, so a change to a widely-used label is proportionally slower. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - label (integer, path): The Label's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, optional): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, optional): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, required): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the next position in that project's label order, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (LabelLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/labels/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "color": "#ef4444" }' ``` --- # DELETE /api/v2/labels/{label} Delete a label - MCP id: labels.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_work_items - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a label. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. The label silently disappears from every work item that carried it; the work items themselves are untouched. Each of those work items is notified, so deleting a widely-used label is proportionally slower. There is no in-use refusal: unlike a work type, a label in use can be deleted. ## Parameters - label (integer, path): The Label's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/labels/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/me Read the token owner - MCP id: me.show - Scope: api:read - Permission: none, the record's own rules still apply - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns the user the token belongs to (the same person every `me` filter value stands for), so a client can show who it is connected as without knowing an id first. It needs a valid token but no permission: any token holding a read ability reaches it, whatever its prefix. The payload is the same `User` that `GET /users/{user}` returns: the detail view adds `working_hours` and `work_locations`, and `view=summary` leaves those two out. `links.self` points to that item, which does need `users::read`. A service account is never returned. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Full name as shown throughout plnnrly. Searchable with `?q=`. - email (string ≤ 255, required): Work email address, unique within the organisation. Searchable with `?q=`, which is the practical way to resolve a person to an id. - color (string ≤ 255, required): The user's identifying colour, as a CSS colour string. Used by plnnrly's calendars and avatars; render it or ignore it. - job_title (string ≤ 255, required): Job title, e.g. Project Manager. - phone_number (string or null ≤ 255, required): Phone number as entered, in no normalised format. - timezone (string or null ≤ 255, required): The user's own IANA timezone, e.g. Europe/Amsterdam. Null means the organisation's default applies. Every timestamp in this API is UTC regardless; this is the zone a client renders them in for this person. - date_employed (date (YYYY-MM-DD) or null, required): First day of employment, or `null` where it was never recorded. A calendar date with no clock, so it is never converted to UTC. - birthday (date (YYYY-MM-DD) or null, required): Date of birth, or `null` where it was never recorded. - profitability_target_percentage (integer or null, required): Share of available hours this person is expected to bill, 1 to 100, or `null` where no target is set. Read by the profitability report; a user without a target is still reported on, just not judged against one. - working_hours (array of object or null, required, detail only): The contracted working week, one entry per shift, in week order. A weekday may carry several entries (a split day is two ranges), and a weekday that is not worked simply has none. Null or empty means no schedule has been recorded. - work_locations (object, required, detail only): Where the user usually works on each weekday, keyed monday through sunday. The stored pattern lists only the days away from the office, so a weekday it does not name reads as office here. A single day's override from the agenda is not reflected. - monday (string, required): Monday. - tuesday (string, required): Tuesday. - wednesday (string, required): Wednesday. - thursday (string, required): Thursday. - friday (string, required): Friday. - saturday (string, required): Saturday. - sunday (string, required): Sunday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while the user is active. - links (UserLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/me' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/notes List notes - MCP id: notes.index - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1. - Cache-Control: private, no-store Returns the notes whose parent record the caller may read, oldest position first. A note is visible exactly when the record it hangs off is: only notes on projects, customers and work items you can see are listed, so this collection can be read across the whole organisation without leaking a record the caller cannot open. Scope it to one record with the `notable_type`/`notable_id` pair, which must be sent together. `total` is omitted unless you pass `with_total=1`. The declared permission is `project_drive::access`; the other three drive prefixes (`customer_drive`, `organization_drive`, `user_drive`) apply by owner type, and a token covering any of the four is accepted. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[notable_id] (integer, optional): Id of the record the note is attached to, within the `notable_type` given. Must be sent together with `notable_type`. Operators: = in. - filter[notable_type] (string, optional): Type of record the note is attached to: `project`, `customer` or `work_item`. Must be sent together with `notable_id`. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Author of the note. Accepts the literal `me`. Operators: = in null. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - content (html, required): Note body as rich-text HTML, sanitised on read. - order_column (integer, required, server-assigned): Sort position within the parent record. Assigned by the server; reordering is not exposed in v2.0. Positions are not counted per parent, so the sequence is effectively creation order across the whole organisation. - user_id (integer or null, required, server-assigned): Author of the note. Always the authenticated user at create time; `null` where the author has since been deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - notable_type (string, required): The kind of record `notable` points at; it says which branch of `notable` applies. - notable_id (integer, required): The id of the record `notable` points at. - notable (Ref or ProjectSummary or CustomerSummary or WorkItemSummary, required): The project, customer or work item this note is attached to. - user (Ref or UserSummary or null, required): Author of the note. - links (NoteLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/notes' \ --data-urlencode 'filter[notable_type]=project' \ --data-urlencode 'filter[notable_id]=1' \ --data-urlencode 'include=user' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/notes Create a note - MCP id: notes.store - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Attaches a note to a project, a customer or a work item and returns it with a `Location` header, mirroring the Notes sub-page in plnnrly. The parent is named by the `notable_type`/`notable_id` pair (one of the few flat creates that accepts a type and id pair), and a parent the caller cannot read is refused exactly as a nonexistent one is. Writing a note also needs the parent's own note permission, as in plnnrly: `projects::notes` for a project, `customers::update` for a customer and `project_management::manage_work_items` for a work item; without it the request is `403` `forbidden-by-policy`. The author is always the authenticated user and cannot be set; the sort position is assigned by the server. The declared permission is `project_drive::create`; the other three drive prefixes apply by owner type. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - content (string ≤ 500000, required): Note body as rich-text HTML, sanitised on read. - notable_type (string, required): The project, customer or work item this note is attached to. - notable_id (integer, required): The project, customer or work item this note is attached to. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - content (html, required): Note body as rich-text HTML, sanitised on read. - order_column (integer, required, server-assigned): Sort position within the parent record. Assigned by the server; reordering is not exposed in v2.0. Positions are not counted per parent, so the sequence is effectively creation order across the whole organisation. - user_id (integer or null, required, server-assigned): Author of the note. Always the authenticated user at create time; `null` where the author has since been deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - notable_type (string, required): The kind of record `notable` points at; it says which branch of `notable` applies. - notable_id (integer, required): The id of the record `notable` points at. - notable (Ref or ProjectSummary or CustomerSummary or WorkItemSummary, required): The project, customer or work item this note is attached to. - user (Ref or UserSummary or null, required): Author of the note. - links (NoteLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/notes' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "notable_type": "project", "notable_id": 1, "content": "

Kick-off moved to Monday; the client will confirm scope by Friday.

" }' ``` --- # GET /api/v2/notes/{note} Read a note - MCP id: notes.show - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: project_drive::access - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one note in the detail view. The summary and detail payloads are identical on this resource: the body is rendered in both, because plnnrly shows note bodies inline in its list. `notable` and `user` render as {"id": N} references until named in include. There is no soft delete: a deleted note is gone, and `trashed` is not an accepted parameter. The declared permission is `project_drive::access`; the other three drive prefixes apply by owner type, and whether you can see the parent record is what actually decides. ## Parameters - note (integer, path): The Note's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - content (html, required): Note body as rich-text HTML, sanitised on read. - order_column (integer, required, server-assigned): Sort position within the parent record. Assigned by the server; reordering is not exposed in v2.0. Positions are not counted per parent, so the sequence is effectively creation order across the whole organisation. - user_id (integer or null, required, server-assigned): Author of the note. Always the authenticated user at create time; `null` where the author has since been deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - notable_type (string, required): The kind of record `notable` points at; it says which branch of `notable` applies. - notable_id (integer, required): The id of the record `notable` points at. - notable (Ref or ProjectSummary or CustomerSummary or WorkItemSummary, required): The project, customer or work item this note is attached to. - user (Ref or UserSummary or null, required): Author of the note. - links (NoteLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/notes/1' \ --data-urlencode 'include=notable,user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/notes/{note} Update a note - MCP id: notes.update - Scope: api:write:project_drive - Scope: api:write:customer_drive - Scope: api:write:organization_drive - Scope: api:write:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a note: absent keys are untouched, an empty body is rejected. `content` is the only writable key: a note cannot be re-parented, so neither half of the notable pair is accepted, matching plnnrly, whose only per-note edit action writes the body. Editing needs the parent's note permission (`projects::notes`, `customers::update` or `project_management::manage_work_items` by parent type), or the request is `403` `forbidden-by-policy`. Writing a note touches its parent record's `updated_at`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. The declared permission is `project_drive::create` (editing is part of the create permission), and the other three drive prefixes apply by owner type. ## Parameters - note (integer, path): The Note's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - content (string ≤ 500000, optional): Note body as rich-text HTML, sanitised on read. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - content (html, required): Note body as rich-text HTML, sanitised on read. - order_column (integer, required, server-assigned): Sort position within the parent record. Assigned by the server; reordering is not exposed in v2.0. Positions are not counted per parent, so the sequence is effectively creation order across the whole organisation. - user_id (integer or null, required, server-assigned): Author of the note. Always the authenticated user at create time; `null` where the author has since been deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - notable_type (string, required): The kind of record `notable` points at; it says which branch of `notable` applies. - notable_id (integer, required): The id of the record `notable` points at. - notable (Ref or ProjectSummary or CustomerSummary or WorkItemSummary, required): The project, customer or work item this note is attached to. - user (Ref or UserSummary or null, required): Author of the note. - links (NoteLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/notes/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "content": "

Kick-off confirmed for Monday 09:00.

" }' ``` --- # DELETE /api/v2/notes/{note} Delete a note - MCP id: notes.destroy - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: project_drive::create - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a note. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Deleting an already-deleted note answers `404`. Deleting needs the parent's note permission (`projects::notes`, `customers::update` or `project_management::manage_work_items` by parent type), or the request is `403` `forbidden-by-policy`. The parent record is unaffected. The declared permission is `project_drive::create` (the same permission as editing), and the other three drive prefixes apply by owner type. ## Parameters - note (integer, path): The Note's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/notes/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/project-links List project links - MCP id: project-links.index - Scope: api:read:projects - Permission: projects::links - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the organisation's project links, sorted by name A→Z. Pass `filter[project_id]` to read one project's links: there is no `projects/{project}/project-links` path. Every project link operation requires the `projects::links` permission, not the projects prefix's usual create, read, update and delete permissions. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[project_id] (integer, optional): Owning project. This is how you list one project's links: there is no nested path for them. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. - project (Ref or ProjectSummary, required): The project this link belongs to. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (string, required, multiline): The link target, e.g. `https://staging.example.test`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ProjectLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/project-links' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'q=staging' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/project-links Create a project link - MCP id: project-links.store - Scope: api:write:projects - Permission: projects::links - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Pins a named URL to the project named in the body and returns it with a `Location` header. `project_id`, name and url are all required, and a link cannot be created against a soft-deleted project. The write also bumps the owning project's `updated_at`, so the project's `ETag` changes and it reappears in an incremental `filter[updated_at][gte]` window, and it is recorded in the project's activity timeline rather than a timeline of its own. Every project link operation requires the `projects::links` permission, not the projects prefix's usual create, read, update and delete permissions. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): Owning project. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (url, required): The link target, e.g. `https://staging.example.test`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. - project (Ref or ProjectSummary, required): The project this link belongs to. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (string, required, multiline): The link target, e.g. `https://staging.example.test`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ProjectLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/project-links' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "name": "Staging", "url": "https://staging.example.test" }' ``` --- # GET /api/v2/project-links/{project_link} Read a project link - MCP id: project-links.show - Scope: api:read:projects - Permission: projects::links - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one project link. Every project link operation requires the `projects::links` permission, not the projects prefix's usual create, read, update and delete permissions. ## Parameters - project_link (integer, path): The ProjectLink's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. - project (Ref or ProjectSummary, required): The project this link belongs to. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (string, required, multiline): The link target, e.g. `https://staging.example.test`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ProjectLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/project-links/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/project-links/{project_link} Update a project link - MCP id: project-links.update - Scope: api:write:projects - Permission: projects::links - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a project link's name and url: absent keys are untouched and an empty body is rejected. `project_id` is not accepted, so a link cannot be moved between projects. The write bumps the owning project's `updated_at` and lands in the project's activity timeline. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Every project link operation requires the `projects::links` permission, not the projects prefix's usual create, read, update and delete permissions. ## Parameters - project_link (integer, path): The ProjectLink's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (url, optional): The link target, e.g. `https://staging.example.test`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. - project (Ref or ProjectSummary, required): The project this link belongs to. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (string, required, multiline): The link target, e.g. `https://staging.example.test`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ProjectLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/project-links/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Staging (EU)" }' ``` --- # DELETE /api/v2/project-links/{project_link} Delete a project link - MCP id: project-links.destroy - Scope: api:destroy:projects - Permission: projects::links - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Deletes a project link permanently: it uses no soft deletes, so there is no restore and no force parameter. The delete bumps the owning project's `updated_at` and is recorded in the project's activity timeline. The bulk delete in plnnrly maps to one DELETE per link; there is no bulk endpoint. Every project link operation requires the `projects::links` permission, not the projects prefix's usual create, read, update and delete permissions. ## Parameters - project_link (integer, path): The ProjectLink's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/project-links/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/projects List projects - MCP id: projects.index - Scope: api:read:projects - Permission: projects::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1 (to-many +3), `q` +2. - Cache-Control: private, no-store Returns the projects the caller may read, with their customer, sub-customer, project manager and account manager inlined. The default sort is title A→Z, not recent activity; pass `sort=-last_activity` for that. Defaults to the summary view; pass `view=detail` for the full payload at three read units instead of one. Pass `include=logged_minutes` for the worklog total the caller may see. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[account_manager_id] (integer, optional): Responsible account manager. Accepts the literal `me`. Operators: = in null. - filter[application_id] (integer, optional): Linked application, managed by platform administrators. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[customer.code] (string, optional): Short code of the owning customer, e.g. ACM. - filter[customer_id] (integer, optional): Owning customer. Operators: = in null. - filter[end_date] (date (YYYY-MM-DD), optional): Last day of the project. Operators: = gte lte null. - filter[is_billable] (boolean, optional): Whether time is billable. - filter[is_continuous] (boolean, optional): Whether the project is open-ended. - filter[last_activity] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Most recent write anywhere in the project. Operators: = gte lt null. - filter[project_manager_id] (integer, optional): Responsible project manager. Accepts the literal `me`. Operators: = in null. - filter[start_date] (date (YYYY-MM-DD), optional): First day of the project. Operators: = gte lte null. - filter[status] (string, optional): Lifecycle status of the project. Operators: = ne in. - filter[sub_customer_id] (integer, optional): Sub-customer. Operators: = in null. - filter[tags.title] (string, optional): Projects carrying this exact tag title. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[workflow_id] (integer, optional): Workflow the project's work items move through. Operators: = in null. - filter[workflow_mode] (string, optional): Sprints or a simple board. - q (string, optional): Free-text search over `title`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/projects' \ --data-urlencode 'filter[status]=active' \ --data-urlencode 'filter[customer_id]=1' \ --data-urlencode 'sort=-start_date' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/projects Create a project - MCP id: projects.store - Scope: api:write:projects - Permission: projects::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a project and returns it in the detail view with a `Location` header. The account manager defaults to the authenticated user and cannot be set directly. `workflow_mode` is required even on an organisation that does not use sprints, and the project's workflow itself is configured in plnnrly rather than here. Pass `tag_ids` to set the project's tags; the set is written whole. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - title (string ≤ 255, required): Project title as shown in the UI. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string, required): Whether work is planned in sprints or on a simple board. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - customer_id (integer → Customer.id, required): Owning customer. - description (string or null, optional): Free-text description. HTML from the rich editor, sanitised. - start_date (date (YYYY-MM-DD) or null, optional): First day of the project. - end_date (date (YYYY-MM-DD) or null, optional): Last day, `null` if open-ended. Must be on or after start_date. - sub_customer_id (integer or null → Customer.id, optional): Optional sub-customer. - project_manager_id (integer or null → User.id, optional): Responsible project manager. - application_id (integer or null → id, optional): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/projects' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "title": "Portal rebuild", "status": "active", "workflow_mode": "sprints", "is_billable": true, "is_continuous": false, "customer_id": 1 }' ``` --- # GET /api/v2/projects/{project} Read a project - MCP id: projects.show - Scope: api:read:projects - Permission: projects::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one project in the detail view, with its counts of sprints, budgets, tags and visible kanban tasks, and links to those collections. A trashed project is `404` unless `trashed=with` is passed and the caller holds `projects::read`. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/projects/1' \ --data-urlencode 'include=tags' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/projects/{project} Update a project - MCP id: projects.update - Scope: api:write:projects - Permission: projects::update - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a project: absent keys are untouched and an empty body is rejected. Setting status to invoiced also finishes every task on every budget of the project, in the same write. `account_manager_id` and `application_id` are not accepted, and a project's workflow is changed in plnnrly. `tag_ids` replaces the tag set whole; an empty array clears it. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - title (string ≤ 255, optional): Project title as shown in the UI. - description (string or null, optional): Free-text description. HTML from the rich editor, sanitised. - status (string, optional): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, optional): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, optional): First day of the project. - end_date (date (YYYY-MM-DD) or null, optional): Last day, `null` if open-ended. Must be on or after start_date. - is_billable (boolean, optional): Whether time on this project is billable. - is_continuous (boolean, optional): Whether the project has no planned end. - customer_id (integer → Customer.id, optional): Owning customer. - sub_customer_id (integer or null → Customer.id, optional): Optional sub-customer. - project_manager_id (integer or null → User.id, optional): Responsible project manager. - application_id (integer or null → id, optional): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - tag_ids (array of integer → Tag.id ≤ 50, optional): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/projects/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "status": "on-hold" }' ``` --- # DELETE /api/v2/projects/{project} Delete a project - MCP id: projects.destroy - Scope: api:destroy:projects - Permission: projects::delete - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes a project; `POST actions/restore` clears the marker again. Pass `force=true` to delete permanently. A project with logged time is refused with `409` `has_logged_time`. A soft delete leaves the project's notes, drive items and tags in place, so a restore brings it back intact; only `force=true` removes them. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - force (boolean, optional): Delete permanently instead of soft-deleting, so the record cannot be restored. It needs no token ability beyond the one every delete needs. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G -X DELETE \ 'https:///api/v2/projects/1' \ --data-urlencode 'force=true' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/projects/{project}/actions/restore Restore a deleted project - MCP id: projects.actions.restore - Scope: api:write:projects - Permission: projects::create - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the project in the detail view. Restoring additionally requires `projects::delete` as well as `projects::create`. A restore fully undoes a soft delete: a soft delete leaves the project's notes, drive items, worklogs and tags in place, so the project comes back with all of them. A force delete cannot be undone. Restoring a project that is not deleted succeeds and returns an `already_in_target_state` warning, so a retry is safe. The restore finds a deleted project without the trashed parameter and does not accept it. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/projects/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/projects/{project}/tasks List a project's tasks - MCP id: projects.tasks.index - Scope: api:read:kanban - Permission: kanban::manage_self - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, `q` +2. - Cache-Control: private, no-store Returns the kanban tasks on one project, lowest board position first. There is no organisation-wide collection of tasks (a kanban board is per-user), so this nested path is the only way to list them, and `filter[project_id]` / `filter[customer_id]` do not exist: the project is the path segment. Tasks are per-user, so `filter[user_id]` defaults to the authenticated user and naming another user requires `kanban::manage_for_others`; without that permission you also do not see the cards left unassigned in plnnrly. Listing additionally requires `projects::read` on the parent project: the declared `kanban::manage_self` is only part of the requirement. A trashed project answers `404` unless `trashed=with` is passed. The summary and detail views are identical on this resource. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[budget_id] (integer, optional): Budget the task is filed under. Combine with the project in the path to narrow a board to one budget. Operators: = in. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[due_date] (date (YYYY-MM-DD), optional): Day the task is due. `null` reaches the cards left undated in plnnrly. Operators: = gte lte null. - filter[is_billable] (boolean, optional): Whether time logged on this task is billable. - filter[is_finished] (boolean, optional): Whether the task is done. Kept in step with `status` automatically, so `filter[is_finished]=true` and `filter[status]=finished` select the same rows. - filter[status] (string, optional): Kanban column the card sits in. Operators: = ne in. - filter[team_id] (integer, optional): Team the task is filed under. Only plnnrly itself assigns one, so tasks created through the API always match `filter[team_id][null]=true`. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose board to return. Accepts the literal `me`, and defaults to the authenticated user; naming another user requires `kanban::manage_for_others`. Operators: = in null. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - budget_id (integer, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - user_id (integer or null, required, server-assigned): The employee the task belongs to. Always the authenticated user on create; not writable. Null on a task left unassigned in plnnrly. - team_id (integer or null, required, server-assigned): Team the task is filed under. Nothing on the API path assigns it, so a task created through the API always carries `null`; plnnrly itself is the only place a team is set. Not writable, and there is no `team` object beside it. - due_date (date (YYYY-MM-DD) or null, required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, required): Optional longer comment on the card, capped at 255 characters. - is_finished (boolean, required): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, required): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. - status (string, required): Kanban column the card sits in. - order_column (integer, required, server-assigned): Position on the kanban board, lowest first. Appended to the end of the board on create and changed when someone drags the card in plnnrly. Not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budget (Ref or BudgetSummary, required): The budget this task is filed under. - user (Ref or UserSummary or null, required): The employee the task belongs to. - links (TaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/projects/1/tasks' \ --data-urlencode 'filter[is_finished]=false' \ --data-urlencode 'sort=due_date,order_column' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/projects/{project}/tasks Create a task on a project - MCP id: projects.tasks.store - Scope: api:write:kanban - Permission: kanban::manage_self - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a kanban task under one of the project's budgets and returns it with a `Location` header pointing at the flat item path. The project comes from the path, so `project_id` is not accepted in the body, and a task has no `project_id` of its own anyway: `budget_id` is the task's only link to a project, it is settable here and nowhere else, and a budget belonging to a different project is rejected as an unprocessable relationship. The task is always created for the authenticated user: `user_id` is not accepted, so creating a card on a colleague's board is not available through the API even to a holder of `kanban::manage_for_others`. Creating additionally requires `projects::read` for the parent project, plus `budgets::read` and a token satisfying `api:read:budgets` for the budget reference; a token created with `api:write:kanban` alone gets a permanent 422 on `budget_id`. A trashed project answers `404` with no escape. ## Parameters - project (integer, path): The Project's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - budget_id (integer → Budget.id, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - due_date (date (YYYY-MM-DD), required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer ≥ 0, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, optional): Optional longer comment on the card, capped at 255 characters. - status (string, optional): Kanban column the card sits in. - is_finished (boolean, optional): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, optional): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - budget_id (integer, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - user_id (integer or null, required, server-assigned): The employee the task belongs to. Always the authenticated user on create; not writable. Null on a task left unassigned in plnnrly. - team_id (integer or null, required, server-assigned): Team the task is filed under. Nothing on the API path assigns it, so a task created through the API always carries `null`; plnnrly itself is the only place a team is set. Not writable, and there is no `team` object beside it. - due_date (date (YYYY-MM-DD) or null, required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, required): Optional longer comment on the card, capped at 255 characters. - is_finished (boolean, required): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, required): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. - status (string, required): Kanban column the card sits in. - order_column (integer, required, server-assigned): Position on the kanban board, lowest first. Appended to the end of the board on create and changed when someone drags the card in plnnrly. Not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budget (Ref or BudgetSummary, required): The budget this task is filed under. - user (Ref or UserSummary or null, required): The employee the task belongs to. - links (TaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/projects/1/tasks' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "budget_id": 1, "description": "Wire up the SSO callback", "due_date": "2026-09-15", "minutes": 240 }' ``` --- # GET /api/v2/sales-invoice-rows/{sales_invoice_row} Read an invoice line - MCP id: sales-invoice-rows.show - Scope: api:read:sales_invoices - Permission: sales_invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one invoice line in the detail view. Lines are soft-deleted, so a deleted line is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live line. This flat path performs no lookup of the parent invoice: a line is readable by id to any caller holding `sales_invoices::read`, which is what plnnrly does too. ## Parameters - sales_invoice_row (integer, path): The SalesInvoiceRow's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - sales_invoice_id (integer, required, server-assigned): The invoice this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another invoice. - sales_invoice (Ref or SalesInvoiceSummary, required): The invoice this line belongs to. - subscription_item_id (integer or null, required): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - budget_id (integer or null, required): Project budget this invoice line settles, when the row was generated from budget hours. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - description (string or null, required, multiline): Longer line text. Exact Online prints this in preference to name. - quantity (decimal (2 dp), required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. - unit_price (decimal (2 dp), required): Price per unit, excluding VAT. This is what actually bills. - hourly_rate (decimal (2 dp) or null, required): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer, required): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, required): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line: quantity times `unit_price` times `vat_rate`, rounded to two decimals. Recomputed by the server whenever one of those three changes. - total_amount (decimal (2 dp), required, server-assigned): Line total excluding VAT (quantity times `unit_price`), which is the convention the invoice form writes. Recomputed by the server whenever quantity or `unit_price` changes. - sort_order (integer, required, server-assigned): Position of the line within its invoice, and the default sort key. Assigned on create; there is no way to reorder lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceRowLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sales-invoice-rows/1' \ --data-urlencode 'include=sales_invoice' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/sales-invoice-rows/{sales_invoice_row} Update an invoice line - MCP id: sales-invoice-rows.update - Scope: api:write:sales_invoices - Permission: sales_invoices::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one line: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. `vat_amount` and `total_amount` are server-assigned: changing any of quantity, `unit_price` or `vat_rate` recomputes both, as the invoice form does. The invoice's subtotal, `vat_total` and total are recalculated in the same transaction. A line of a legacy or synced invoice answers `409` `state-conflict`. A caller without `sales_invoices::finalize` also returns the invoice to `pending_approval`, as editing the invoice itself does. `sales_invoice_id` and `sort_order` are not accepted: a line cannot be moved to another invoice, and there is no way to reorder lines through the API. ## Parameters - sales_invoice_row (integer, path): The SalesInvoiceRow's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): The line label, as printed on the invoice. Exact Online prints this when description is empty. - quantity (decimal (2 dp) -99999999.99 to 99999999.99, optional): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. At least -99999999.99. At most 99999999.99. - unit_price (decimal (2 dp) -99999999.99 to 99999999.99, optional): Price per unit, excluding VAT. This is what actually bills. At least -99999999.99. At most 99999999.99. - description (string or null, optional): Longer line text. Exact Online prints this in preference to name. - hourly_rate (decimal (2 dp) or null, optional): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer ≥ 0, optional): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, optional): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - budget_id (integer or null → Budget.id, optional): Project budget this invoice line settles, when the row was generated from budget hours. - subscription_item_id (integer or null → SubscriptionItem.id, optional): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - exact_item_id (string or null ≤ 255, optional): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, optional): Exact Online general-ledger revenue account for this line. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - sales_invoice_id (integer, required, server-assigned): The invoice this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another invoice. - sales_invoice (Ref or SalesInvoiceSummary, required): The invoice this line belongs to. - subscription_item_id (integer or null, required): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - budget_id (integer or null, required): Project budget this invoice line settles, when the row was generated from budget hours. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - description (string or null, required, multiline): Longer line text. Exact Online prints this in preference to name. - quantity (decimal (2 dp), required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. - unit_price (decimal (2 dp), required): Price per unit, excluding VAT. This is what actually bills. - hourly_rate (decimal (2 dp) or null, required): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer, required): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, required): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line: quantity times `unit_price` times `vat_rate`, rounded to two decimals. Recomputed by the server whenever one of those three changes. - total_amount (decimal (2 dp), required, server-assigned): Line total excluding VAT (quantity times `unit_price`), which is the convention the invoice form writes. Recomputed by the server whenever quantity or `unit_price` changes. - sort_order (integer, required, server-assigned): Position of the line within its invoice, and the default sort key. Assigned on create; there is no way to reorder lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceRowLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/sales-invoice-rows/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "quantity": "14.00" }' ``` --- # DELETE /api/v2/sales-invoice-rows/{sales_invoice_row} Delete an invoice line - MCP id: sales-invoice-rows.destroy - Scope: api:destroy:sales_invoices - Permission: sales_invoices::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes one line and recalculates the invoice's subtotal, `vat_total` and total in the same transaction. There is no permanent delete and no restore verb: a line comes back by being re-created through `POST sales-invoices/{sales_invoice}/rows`, or by restoring the invoice it belonged to. Because this is a soft delete, worklogs settled against the line keep pointing at it: they are neither shown as re-invoiceable nor released, and re-creating the line does not re-couple them. Only deleting the whole invoice releases its hours. Re-deleting an already-deleted line answers `204`, so a retry is safe. A line of a legacy or synced invoice answers `409` `state-conflict`. A caller without `sales_invoices::finalize` also returns the invoice to `pending_approval`, as editing the invoice itself does. ## Parameters - sales_invoice_row (integer, path): The SalesInvoiceRow's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/sales-invoice-rows/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/sales-invoices List sales invoices - MCP id: sales-invoices.index - Scope: api:read:sales_invoices - Permission: sales_invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the organisation's sales invoices, newest invoice date first. Defaults to the summary view; `view=detail` adds `sync_error` and `internal_notes` and costs three read units instead of one. Search with `q=` over the invoice number, and pass `trashed=with|only` to see deleted invoices. Combine `filter[invoice_date][gte]` and `[lte]` for a period, `filter[status]=draft` or `pending_approval` for the concept and approval queues in plnnrly, and `filter[sync_status]=failed` for invoices the accounting sync rejected. Sorting by total or invoice number is deliberately not offered, so a money-ordered page has to be sorted client-side. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[customer_id] (integer, optional): Customer the invoice is billed to. Operators: = in. - filter[customer_subscription_id] (integer, optional): Customer subscription the invoice was generated from. Pass `[null]` for invoices that were not generated from a renewal. Operators: = in null. - filter[due_date] (date (YYYY-MM-DD), optional): Date payment is due. Pass `[null]` for invoices with no term applied. Operators: = gte lte null. - filter[invoice_date] (date (YYYY-MM-DD), optional): Invoice date, a zone-free calendar date. Combine `[gte]` and `[lte]` for a period. Operators: = gte lte. - filter[is_legacy] (boolean, optional): Whether the invoice was converted from a legacy invoice, and is therefore read-only. - filter[legacy_invoice_id] (integer, optional): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. Operators: = in null. - filter[paid_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the invoice was settled. Pass `[null]` for unsettled invoices. Operators: = gte lte null. - filter[project_id] (integer, optional): Project the invoice was raised for. Pass `[null]` for invoices raised against no project. Operators: = in null. - filter[should_email] (boolean, optional): Whether the invoice is emailed to the customer when it syncs. - filter[status] (string, optional): Lifecycle status of the invoice. Operators: = ne in. - filter[sync_attempted_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the invoice was last handed to the sync, successfully or not. Operators: = gte lte null. - filter[sync_status] (string, optional): How far the invoice has got through the accounting sync. `filter[sync_status]=failed` reproduces the sync error page in plnnrly. Operators: = ne in. - filter[synced_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the invoice last synced successfully. Pass `[null]` for invoices that have never synced. Operators: = gte lte null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `invoice_number`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sales-invoices' \ --data-urlencode 'filter[customer_id]=1' \ --data-urlencode 'filter[invoice_date][gte]=2026-01-01' \ --data-urlencode 'filter[sync_status]=pending' \ --data-urlencode 'sort=-invoice_date' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/sales-invoices Create a sales invoice - MCP id: sales-invoices.store - Scope: api:write:sales_invoices - Permission: sales_invoices::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates an invoice header and returns it in the detail view with a `Location` header. It has no rows yet, so subtotal, `vat_total` and total are 0.00: post each row to `sales-invoices/{sales_invoice}/rows` afterwards and the totals follow. The invoice number is allocated by the server from the organisation's yearly sequence and cannot be supplied. The server sets the status too: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice that a colleague holding that permission must approve in plnnrly; no API endpoint advances it. In an organisation with the accounting integration connected the invoice starts as a draft awaiting the sync, and the customer must be linked to that system or the sync will later fail; in one without it the invoice is local, immediately settled, and never emailed. Send `Idempotency-Key` to make a retry safe. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - customer_id (integer → Customer.id, required): Customer this invoice is billed to. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - project_id (integer or null → Project.id, optional): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null → CustomerSubscription.id, optional): Customer subscription this invoice was generated from, when it was generated from a renewal. - due_date (date (YYYY-MM-DD) or null, optional): Date payment is due, a zone-free calendar date. Null when no term was applied. Must be on or after invoice_date. - reference (string or null ≤ 255, optional): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, optional): One-line description of what was invoiced. - internal_notes (string or null, optional): Notes for colleagues. Never rendered on the invoice the customer receives. - should_email (boolean, optional): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sales-invoices' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "customer_id": 1, "invoice_date": "2026-02-01", "due_date": "2026-03-03", "reference": "PO-4471", "description": "Development work, January 2026" }' ``` --- # GET /api/v2/sales-invoices/{sales_invoice} Read a sales invoice - MCP id: sales-invoices.show - Scope: api:read:sales_invoices - Permission: sales_invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one invoice in the detail view. It is addressable by numeric id and by invoice number on the same path, so `sales-invoices/B-26-00001` and `sales-invoices/L-1234` both resolve. Invoices are soft-deleted, so a deleted invoice is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live one. The line rows are not part of this payload: `rows_count` reports how many there are, and `links.rows` is where they live. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sales-invoices/1' \ --data-urlencode 'include=customer' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/sales-invoices/{sales_invoice} Update a sales invoice - MCP id: sales-invoices.update - Scope: api:write:sales_invoices - Permission: sales_invoices::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates the invoice header: absent keys are untouched, and the line rows are edited through their own endpoints. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. A legacy invoice cannot be changed at all and answers `409` `state-conflict` with reason `locked_by_legacy`. An invoice that has already synced to the accounting system can still be edited here, which goes further than plnnrly: plnnrly closes its edit form once an invoice is synced, and the change will not be pushed to the accounting system again. status is never writable: it is stripped from the payload, and any update by a caller without `sales_invoices::finalize` additionally returns the invoice to `pending_approval`, so an already-approved invoice cannot be changed and synced without a finalizer re-checking it. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - customer_id (integer → Customer.id, optional): Customer this invoice is billed to. - project_id (integer or null → Project.id, optional): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null → CustomerSubscription.id, optional): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_date (date (YYYY-MM-DD), optional): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, optional): Date payment is due, a zone-free calendar date. Null when no term was applied. Must be on or after invoice_date. - reference (string or null ≤ 255, optional): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, optional): One-line description of what was invoiced. - internal_notes (string or null, optional): Notes for colleagues. Never rendered on the invoice the customer receives. - should_email (boolean, optional): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/sales-invoices/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "due_date": "2026-03-31" }' ``` --- # DELETE /api/v2/sales-invoices/{sales_invoice} Delete a sales invoice - MCP id: sales-invoices.destroy - Scope: api:destroy:sales_invoices - Permission: sales_invoices::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes an invoice: it is only marked deleted, and POST `sales-invoices/{sales_invoice}/actions/restore` reverses that. There is no permanent delete. A legacy invoice answers `409` `state-conflict` with reason `locked_by_legacy`, and an invoice that has already synced to the accounting system answers `409` with reason `locked_by_sync`: deleting it locally would leave the two systems silently out of step. Deleting an invoice releases its hours: every worklog settled against one of its rows is unlinked and returns to the uninvoiced pool, and restoring the invoice brings back its rows but not those links, so an integrator treating restore as an undo will invoice those hours twice. Deleting an already-deleted invoice answers `204`, so a retry is safe. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/sales-invoices/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/sales-invoices/{sales_invoice}/actions/restore Restore a deleted sales invoice - MCP id: sales-invoices.actions.restore - Scope: api:write:sales_invoices - Permission: sales_invoices::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the invoice in the detail view. Restoring additionally requires `sales_invoices::delete` as well as `sales_invoices::create`. Restoring is not a full undo: deleting the invoice unlinked every worklog that had been settled against its rows and returned those hours to the uninvoiced pool, and restoring brings back the invoice and its rows but not those links, so the hours can be invoiced a second time unless you check them. Restoring an invoice that is not deleted succeeds and returns an `already_in_target_state` warning. The restore finds a deleted invoice on its own, so the trashed parameter is not accepted. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sales-invoices/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/sales-invoices/{sales_invoice}/rows List an invoice's lines - MCP id: sales-invoices.rows.index - Scope: api:read:sales_invoices - Permission: sales_invoices::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns one sales invoice's lines in their printed order, `sort_order` ascending. There is no organisation-wide collection of invoice lines: a line only means anything under the invoice it belongs to, so this nested path is the only way to list them and `filter[sales_invoice_id]` does not exist. A deleted invoice answers `404` for the whole path unless `trashed=with` is passed, which also includes the invoice's deleted lines. Defaults to the summary view; pass `include=sales_invoice` to expand the invoice each line points at. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[budget_id] (integer, optional): Project budget this invoice line settles. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[is_aggregated_price] (boolean, optional): Whether the line carries one aggregated price rather than hours times an hourly rate. - filter[subscription_item_id] (integer, optional): Subscription line this invoice line was generated from. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - sales_invoice_id (integer, required, server-assigned): The invoice this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another invoice. - sales_invoice (Ref or SalesInvoiceSummary, required): The invoice this line belongs to. - subscription_item_id (integer or null, required): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - budget_id (integer or null, required): Project budget this invoice line settles, when the row was generated from budget hours. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - description (string or null, required, multiline): Longer line text. Exact Online prints this in preference to name. - quantity (decimal (2 dp), required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. - unit_price (decimal (2 dp), required): Price per unit, excluding VAT. This is what actually bills. - hourly_rate (decimal (2 dp) or null, required): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer, required): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, required): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line: quantity times `unit_price` times `vat_rate`, rounded to two decimals. Recomputed by the server whenever one of those three changes. - total_amount (decimal (2 dp), required, server-assigned): Line total excluding VAT (quantity times `unit_price`), which is the convention the invoice form writes. Recomputed by the server whenever quantity or `unit_price` changes. - sort_order (integer, required, server-assigned): Position of the line within its invoice, and the default sort key. Assigned on create; there is no way to reorder lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceRowLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sales-invoices/1/rows' \ --data-urlencode 'trashed=with' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/sales-invoices/{sales_invoice}/rows Add a line to an invoice - MCP id: sales-invoices.rows.store - Scope: api:write:sales_invoices - Permission: sales_invoices::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one line on the invoice named in the path and returns it in the detail view, with a `Location` header pointing at the flat item path. The invoice comes from the path, so `sales_invoice_id` is not accepted in the body and a line can never be moved between invoices. `vat_rate` defaults to the organisation's configured VAT percentage. `vat_amount` and `total_amount` are server-assigned, always computed from quantity, `unit_price` and `vat_rate`; `total_amount` excludes VAT. The invoice's own subtotal, `vat_total` and total are recalculated from all of its lines in the same transaction. A deleted invoice answers `404`, and a legacy or synced invoice answers `409` `state-conflict`. A caller without `sales_invoices::finalize` also returns the invoice to `pending_approval`, as editing the invoice itself does. `exact_item_id` is optional here as it is in plnnrly, but in an organisation using Exact a line without it fails the whole invoice's sync, and naming an `exact_item_id` does not auto-fill the VAT code, GL account or hourly rate plnnrly would fill in from it. ## Parameters - sales_invoice (integer or string, path): The SalesInvoice's numeric id. Or its invoice_number, matching `[A-Z]+-[0-9]{2}-[0-9]{5}|L-[0-9]+`. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - quantity (decimal (2 dp) -99999999.99 to 99999999.99, required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. At least -99999999.99. At most 99999999.99. - unit_price (decimal (2 dp) -99999999.99 to 99999999.99, required): Price per unit, excluding VAT. This is what actually bills. At least -99999999.99. At most 99999999.99. - description (string or null, optional): Longer line text. Exact Online prints this in preference to name. - hourly_rate (decimal (2 dp) or null, optional): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer ≥ 0, optional): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, optional): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - budget_id (integer or null → Budget.id, optional): Project budget this invoice line settles, when the row was generated from budget hours. - subscription_item_id (integer or null → SubscriptionItem.id, optional): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - exact_item_id (string or null ≤ 255, optional): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, optional): Exact Online general-ledger revenue account for this line. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - sales_invoice_id (integer, required, server-assigned): The invoice this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another invoice. - sales_invoice (Ref or SalesInvoiceSummary, required): The invoice this line belongs to. - subscription_item_id (integer or null, required): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - budget_id (integer or null, required): Project budget this invoice line settles, when the row was generated from budget hours. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - description (string or null, required, multiline): Longer line text. Exact Online prints this in preference to name. - quantity (decimal (2 dp), required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. - unit_price (decimal (2 dp), required): Price per unit, excluding VAT. This is what actually bills. - hourly_rate (decimal (2 dp) or null, required): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer, required): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, required): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line: quantity times `unit_price` times `vat_rate`, rounded to two decimals. Recomputed by the server whenever one of those three changes. - total_amount (decimal (2 dp), required, server-assigned): Line total excluding VAT (quantity times `unit_price`), which is the convention the invoice form writes. Recomputed by the server whenever quantity or `unit_price` changes. - sort_order (integer, required, server-assigned): Position of the line within its invoice, and the default sort key. Assigned on create; there is no way to reorder lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceRowLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sales-invoices/1/rows' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Sprint 42: development", "description": "Feature work on the customer portal.", "quantity": "12.50", "unit_price": "95.00" }' ``` --- # GET /api/v2/sprints List sprints - MCP id: sprints.index - Scope: api:read:project_management - Permission: project_management::view_active_sprint - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the organisation's sprints in each project's display order, with the id as the tiebreaker. The list covers the whole organisation and interleaves projects, because `order` is unique within a project only; narrow it with `filter[project_id]`. A sprint's state is derived rather than stored, so there is no `filter[state]`; compose it instead: `filter[is_active]=true&filter[completed_at][null]=true` is the running sprint, `filter[is_active]=false&filter[completed_at][null]=true` the upcoming ones, and `filter[completed_at][null]=false` the history. Listing additionally requires `project_management::manage_sprints`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[completed_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the sprint was actually completed. `filter[completed_at][null]=false` is every completed sprint and `filter[completed_at][null]=true` every sprint still open. Operators: = gte lte null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[end_date] (date (YYYY-MM-DD), optional): Planned last day of the sprint. Operators: = gte lte null. - filter[is_active] (boolean, optional): Whether the sprint is the project's running one. Combine with `filter[completed_at][null]` to express a sprint state; this filter alone does not distinguish upcoming from completed. - filter[project_id] (integer, optional): Narrow the list to one project's sprints, or to several with `in`. Operators: = in. - filter[start_date] (date (YYYY-MM-DD), optional): Planned first day of the sprint. Operators: = gte lte null. - filter[started_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the sprint was actually started. `filter[started_at][null]=true` is every sprint that has never run. Operators: = gte lte null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sprints' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'filter[is_active]=false' \ --data-urlencode 'filter[completed_at][null]=true' \ --data-urlencode 'sort=order' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/sprints Create a sprint - MCP id: sprints.store - Scope: api:write:project_management - Permission: project_management::manage_sprints - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates an upcoming sprint on a project and returns it with a `Location` header: `is_active` is `false` and `started_at` and `completed_at` are `null`, whatever `start_date` and `end_date` say; planning dates does not start a sprint. Start it with POST `sprints/{sprint}/actions/start`. `order` is not accepted: the new sprint is placed last within its project and is reordered only in plnnrly. `project_id` is accepted here and only here: a sprint cannot be moved between projects. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, optional): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, optional): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, optional): Planned last day of the sprint. Independent of `completed_at`. Must be on or after start_date. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sprints' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "name": "Sprint 14", "description": "Finish the invoicing export.", "start_date": "2026-03-02", "end_date": "2026-03-13" }' ``` --- # GET /api/v2/sprints/{sprint} Read a sprint - MCP id: sprints.show - Scope: api:read:project_management - Permission: project_management::view_active_sprint - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one sprint in the detail view. The state is derived, not stored: `completed_at` non-`null` means completed, otherwise `is_active` `true` means active, otherwise upcoming. `work_items_count` is the number of items on the sprint and `links.work_items` is the URL that lists them. Reading additionally requires `project_management::manage_sprints`. ## Parameters - sprint (integer, path): The Sprint's numeric id. - view (string, optional): Which field set to render. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/sprints/1' \ --data-urlencode 'include=project' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/sprints/{sprint} Update a sprint - MCP id: sprints.update - Scope: api:write:project_management - Permission: project_management::manage_sprints - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a sprint's name, description or planned dates: absent keys are untouched, an empty body is rejected. `project_id` is not accepted: a sprint cannot be moved between projects, because its work items and its `order` position are both scoped to the project it was created on. Neither are `order`, `is_active`, `started_at` or `completed_at`: the first is read-only and the other three belong to the start and complete actions. ⚠ `end_date` is checked against `start_date` only when both are sent, so a PATCH carrying an `end_date` earlier than the stored `start_date` succeeds; send the pair together if you need the ordering enforced. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - sprint (integer, path): The Sprint's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, optional): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, optional): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, optional): Planned last day of the sprint. Independent of `completed_at`. Must be on or after start_date. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/sprints/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Sprint 14: invoicing", "end_date": "2026-03-20" }' ``` --- # DELETE /api/v2/sprints/{sprint} Delete a sprint - MCP id: sprints.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_sprints - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a sprint. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. ⚠ This endpoint deliberately allows more than plnnrly, which refuses to delete a sprint that is active, completed or still holding work items: over the API all three succeed. The sprint's work items silently return to the project backlog: no work-item event fires, no live update goes out and the affected items' `version` is not bumped. A client holding their ETags will see no change and must re-read them. To close a sprint and keep its history, complete it with POST `sprints/{sprint}/actions/complete` instead. ## Parameters - sprint (integer, path): The Sprint's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/sprints/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/sprints/{sprint}/actions/complete Complete a sprint - MCP id: sprints.actions.complete - Scope: api:write:project_management - Permission: project_management::manage_sprints - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Closes a sprint: clears `is_active` and stamps `completed_at` with the current time, then disposes of the sprint's unresolved work items (those whose workflow status is not a resolution) according to `unresolved_work_items`. Resolved items always stay on the completed sprint, whichever strategy is chosen, so the closed sprint keeps its delivery history. `move_to_next_sprint` additionally requires `next_sprint_id`, which must name a not-yet-completed sprint on the same project other than this one; anything else is `422` `unprocessable-relationship` pointing at `/next_sprint_id`. A sprint that is already completed refuses with `409` `state-conflict` and `conflict.reason` `already_completed`, and one that was never started refuses the same way with `not_started`. Each moved item is saved on its own, so its audit trail records the move and its `version` is bumped (a client holding their ETags must re-read them), but no per-item notification is sent. Returns the sprint in the detail view; `work_items_count` reflects what is left on it. ## Parameters - sprint (integer, path): The Sprint's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - unresolved_work_items (string, required): What happens to the sprint's unresolved work items. - next_sprint_id (integer or null → Sprint.id, optional): Sprint that receives the unresolved work items. Required, and only used, with `move_to_next_sprint`; it must be a not-yet-completed sprint on the same project. Required when unresolved_work_items, move_to_next_sprint. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sprints/1/actions/complete' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "unresolved_work_items": "move_to_next_sprint", "next_sprint_id": 1 }' ``` --- # POST /api/v2/sprints/{sprint}/actions/start Start a sprint - MCP id: sprints.actions.start - Scope: api:write:project_management - Permission: project_management::manage_sprints - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Activates a sprint: sets `is_active` to `true` and stamps `started_at` with the current time. The planned `start_date` and `end_date` are left alone (planning dates and running a sprint are independent), and `order` is not touched. At most one sprint per project may run at a time, so three states refuse with `409` `state-conflict`: a completed sprint (`conflict.reason` `already_completed`), a sprint that is already running (`already_started`), and a sprint whose project already has a different running sprint (`already_started`, with the blocking sprint named in the conflict member; complete it first). Returns the sprint in the detail view with a fresh `ETag`. Takes no request body. ## Parameters - sprint (integer, path): The Sprint's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/sprints/1/actions/start' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/subscription-items/{subscription_item} Read a subscription line - MCP id: subscription-items.show - Scope: api:read:customer_subscriptions - Permission: customer_subscriptions::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one subscription line in the detail view. The description is published exactly as stored, with any {{$domain}}-style placeholders unresolved: those are filled in only when a renewal turns the line into an invoice row. Lines are soft-deleted, so a deleted line is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live line. This flat path performs no lookup of the parent subscription: a line is readable by id to any caller holding `customer_subscriptions::read`, which is what plnnrly does too. ## Parameters - subscription_item (integer, path): The SubscriptionItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - customer_subscription_id (integer, required, server-assigned): The subscription this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another subscription. - customer_subscription (Ref or CustomerSubscriptionSummary, required): The subscription this line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. - description (string or null, required, multiline): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer, required): Number of units billed per period. - purchase_price (decimal (2 dp) ≥ 0, required): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. - sales_price (decimal (2 dp) ≥ 0, required): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line, computed by the server: quantity x `sales_price` x `vat_rate` / 100. Read-only. - total_amount (decimal (2 dp), required, server-assigned): Line total including VAT (quantity x `sales_price` plus the VAT above), computed by the server. Note this is the opposite convention from `SalesInvoiceRow.total_amount`, which excludes VAT: adding VAT to this value double-counts it. Read-only. - sort_order (integer, required, server-assigned): Position of the line within its subscription, and the default sort key. Assigned on create; there is no way to reorder lines through the API. Only the ordering is meaningful: values are not unique across subscriptions. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. This is also the `ETag` revision: a subscription line has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subscription-items/1' \ --data-urlencode 'include=subscribable' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/subscription-items/{subscription_item} Update a subscription line - MCP id: subscription-items.update - Scope: api:write:customer_subscriptions - Permission: customer_subscriptions::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one line: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. `vat_amount` and `total_amount` are recomputed by the server from quantity, `sales_price` and `vat_rate` and are never accepted. `customer_subscription_id` and `sort_order` are not accepted either: a line cannot be moved to another subscription, and there is no way to reorder lines through the API. Repointing subscribable away from a hosting product also clears the hosting site or server linked to the line in plnnrly; that link is not published by this API, so send subscribable only when you mean to change what is being sold. A deleted line answers `404` unless `trashed=with` is passed. ## Parameters - subscription_item (integer, path): The SubscriptionItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - description (string or null, optional): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer ≥ 1, optional): Number of units billed per period. - purchase_price (decimal (2 dp) 0 to 99999999.99, optional): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. At least 0. At most 99999999.99. - sales_price (decimal (2 dp) 0 to 99999999.99, optional): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. At least 0. At most 99999999.99. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - subscribable_type (string or null, optional): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. Required when subscribable_id is present. - subscribable_id (integer or null → SubscriptionProduct.id, optional): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. Required when subscribable_type is present. - exact_item_id (string or null ≤ 255, optional): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, optional): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost-centre code. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - customer_subscription_id (integer, required, server-assigned): The subscription this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another subscription. - customer_subscription (Ref or CustomerSubscriptionSummary, required): The subscription this line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. - description (string or null, required, multiline): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer, required): Number of units billed per period. - purchase_price (decimal (2 dp) ≥ 0, required): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. - sales_price (decimal (2 dp) ≥ 0, required): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line, computed by the server: quantity x `sales_price` x `vat_rate` / 100. Read-only. - total_amount (decimal (2 dp), required, server-assigned): Line total including VAT (quantity x `sales_price` plus the VAT above), computed by the server. Note this is the opposite convention from `SalesInvoiceRow.total_amount`, which excludes VAT: adding VAT to this value double-counts it. Read-only. - sort_order (integer, required, server-assigned): Position of the line within its subscription, and the default sort key. Assigned on create; there is no way to reorder lines through the API. Only the ordering is meaningful: values are not unique across subscriptions. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. This is also the `ETag` revision: a subscription line has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/subscription-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "sales_price": "14.95" }' ``` --- # DELETE /api/v2/subscription-items/{subscription_item} Delete a subscription line - MCP id: subscription-items.destroy - Scope: api:destroy:customer_subscriptions - Permission: customer_subscriptions::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes one line. There is no permanent delete and no restore verb: a line comes back by being re-created through `POST customer-subscriptions/{customer_subscription}/items`, or by restoring the subscription it belonged to. Because this is a soft delete, invoice rows a past renewal generated from the line keep pointing at it. Removing the last line of a subscription is allowed, but that subscription can no longer be invoiced: its next renewal run logs a failure instead of producing an invoice. Re-deleting an already-deleted line answers `204`, so a retry is safe. ## Parameters - subscription_item (integer, path): The SubscriptionItem's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/subscription-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/subscription-products List subscription products - MCP id: subscription-products.index - Scope: api:read:subscription_products - Permission: subscription_products::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's sellable subscription products, sorted by name A to Z. Defaults to the summary view; `view=detail` carries the same fields on this resource (no field of it is detail-only) and costs three read units instead of one. Search with `q=` over the product name, and pass `trashed=with|only` to see deleted products. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[is_hosting] (boolean, optional): Whether the product is sold against a concrete site or server. - filter[price_interval] (string, optional): Billing interval the product is priced per. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subscription-products' \ --data-urlencode 'filter[is_hosting]=true' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/subscription-products Create a subscription product - MCP id: subscription-products.store - Scope: api:write:subscription_products - Permission: subscription_products::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a product and returns it in the detail view with a `Location` header. Names are unique and deleted products count, so a name that matches a soft-deleted product is a `422` `validation-failed` with code unique; restore that product with POST `subscription-products/{subscription_product}/actions/restore` rather than retrying under a different name. `price_interval` is required: it has no default, and the monthly pre-selection in plnnrly's form is a convenience only. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required, unique): Product name as shown in the UI and on the subscription line. Must be unique across SubscriptionProduct. - price_interval (string, required): Interval the sales price is charged per. - description (string or null, optional): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - is_hosting (boolean, optional): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - purchase_price (decimal (2 dp) or null ≥ 0, optional): What the product costs us, as a decimal string. Null when unknown. At least 0. - sales_price (decimal (2 dp) or null ≥ 0, optional): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. At least 0. - vat_rate (integer or null 0 to 100, optional): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, optional): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/subscription-products' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Managed hosting, small", "description": "Managed hosting for {{$site}} over {{$period}}.", "price_interval": "monthly", "is_hosting": true, "sales_price": "49.50", "vat_rate": 21 }' ``` --- # GET /api/v2/subscription-products/{subscription_product} Read a subscription product - MCP id: subscription-products.show - Scope: api:read:subscription_products - Permission: subscription_products::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one product in the detail view. Products are soft-deleted, so a deleted product is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live product. ## Parameters - subscription_product (integer, path): The SubscriptionProduct's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/subscription-products/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/subscription-products/{subscription_product} Update a subscription product - MCP id: subscription-products.update - Scope: api:write:subscription_products - Permission: subscription_products::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a product: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Renaming a product onto another product's name, a deleted one included, is a `422` `validation-failed` with code unique. Existing subscription lines keep the price they were created with; a price change here applies to lines created afterwards. ## Parameters - subscription_product (integer, path): The SubscriptionProduct's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional, unique): Product name as shown in the UI and on the subscription line. Must be unique across SubscriptionProduct. - description (string or null, optional): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - price_interval (string, optional): Interval the sales price is charged per. - is_hosting (boolean, optional): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - purchase_price (decimal (2 dp) or null ≥ 0, optional): What the product costs us, as a decimal string. Null when unknown. At least 0. - sales_price (decimal (2 dp) or null ≥ 0, optional): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. At least 0. - vat_rate (integer or null 0 to 100, optional): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, optional): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, optional): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, optional): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/subscription-products/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "sales_price": "54.95" }' ``` --- # DELETE /api/v2/subscription-products/{subscription_product} Delete a subscription product - MCP id: subscription-products.destroy - Scope: api:destroy:subscription_products - Permission: subscription_products::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes a product: it is only marked deleted, and POST `subscription-products/{subscription_product}/actions/restore` reverses it. There is no permanent delete. Subscription lines and subscription-type lines that point at this product through `subscribable` are not deleted and not cleared, so those lines keep referencing a product that has become invisible to the catalogue, and a deleted hosting product stops reading as a hosting product in plnnrly until it is restored. Deleting a product does not release its name: deleted products count towards uniqueness. ## Parameters - subscription_product (integer, path): The SubscriptionProduct's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/subscription-products/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/subscription-products/{subscription_product}/actions/restore Restore a deleted subscription product - MCP id: subscription-products.actions.restore - Scope: api:write:subscription_products - Permission: subscription_products::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the product in the detail view. Restoring additionally requires `subscription_products::delete` as well as `subscription_products::create`. Restoring a product that is not deleted succeeds and returns an `already_in_target_state` warning. The restore finds a deleted product on its own, so the trashed parameter is not accepted. This is also the remedy for a `422` unique on the name: the uniqueness check counts deleted products, so restoring the product that holds the name is the fix, not retrying under another one. ## Parameters - subscription_product (integer, path): The SubscriptionProduct's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/subscription-products/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/subscription-type-items/{subscription_type_item} Read a template line - MCP id: subscription-type-items.show - Scope: api:read:subscription_types - Permission: subscription_types::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one template line in the detail view. Template lines are soft-deleted, so a deleted line is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live line. This flat path performs no lookup of the parent subscription type: a line is readable by id to any caller holding `subscription_types::read`, which is what plnnrly does too. `subscribable_type` can read back as domain or server on a line pointed at a hosting record in plnnrly, in which case subscribable is the bare {"id": N} reference rather than an expandable object. ## Parameters - subscription_type_item (integer, path): The SubscriptionTypeItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - subscription_type_id (integer, required, server-assigned): The subscription type this template line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another type. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this template line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. - name (string or null ≤ 255, required): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, required, multiline): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer, required): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp), required): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. - default_sales_price (decimal (2 dp), required): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - sort_order (integer, required, server-assigned): Position of the line within its subscription type, and the default sort key. Assigned on create; there is no way to reorder template lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionTypeItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subscription-type-items/1' \ --data-urlencode 'include=subscription_type,subscribable' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/subscription-type-items/{subscription_type_item} Update a template line - MCP id: subscription-type-items.update - Scope: api:write:subscription_types - Permission: subscription_types::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one template line: absent keys are untouched, and an empty body is `422`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. `subscription_type_id` and `sort_order` are not accepted: a line cannot be moved to another type, and there is no way to reorder template lines through the API. `subscribable_type` and `subscribable_id` stay all-or-nothing: send both to re-point the line at another subscription product, or both as `null` to clear it. Only `subscription_product` is accepted, so a line pointed at a domain or a server in plnnrly can be read but never re-pointed here. Nothing is recalculated on the subscription type, and the change does not reach customer subscriptions already created from this template: that copy is taken by value at pick time. ## Parameters - subscription_type_item (integer, path): The SubscriptionTypeItem's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string or null ≤ 255, optional): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, optional): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer ≥ 1, optional): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp) -99999999.99 to 99999999.99, optional): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. At least -99999999.99. At most 99999999.99. - default_sales_price (decimal (2 dp) -99999999.99 to 99999999.99, optional): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. At least -99999999.99. At most 99999999.99. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - subscribable_type (string or null, optional): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. Required when subscribable_id is present. - subscribable_id (integer or null → SubscriptionProduct.id, optional): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. Required when subscribable_type is present. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - subscription_type_id (integer, required, server-assigned): The subscription type this template line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another type. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this template line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. - name (string or null ≤ 255, required): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, required, multiline): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer, required): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp), required): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. - default_sales_price (decimal (2 dp), required): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - sort_order (integer, required, server-assigned): Position of the line within its subscription type, and the default sort key. Assigned on create; there is no way to reorder template lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionTypeItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/subscription-type-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "default_sales_price": "34.95" }' ``` --- # DELETE /api/v2/subscription-type-items/{subscription_type_item} Delete a template line - MCP id: subscription-type-items.destroy - Scope: api:destroy:subscription_types - Permission: subscription_types::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes one template line. There is no permanent delete and no restore verb: a line comes back by being re-created through POST `subscription-types/{subscription_type}/items`, or by restoring the type it belonged to. Customer subscriptions already built from this template keep their own lines untouched (those were copied by value when the type was picked, not referenced), so deleting a template line changes nothing that is currently billing. Re-deleting an already-deleted line answers `204`, so a retry is safe. ## Parameters - subscription_type_item (integer, path): The SubscriptionTypeItem's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/subscription-type-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/subscription-types List subscription types - MCP id: subscription-types.index - Scope: api:read:subscription_types - Permission: subscription_types::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's subscription types in catalogue order: `sort_order` ascending, the same order plnnrly shows. Defaults to the summary view; `view=detail` carries the same fields on this resource (no field of it is detail-only) and costs three read units instead of one. Search with `q=` over the type name, and pass `trashed=with|only` to see deleted types. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[active] (boolean, optional): Whether the type is offered in the catalogue. - filter[auto_renew] (boolean, optional): Whether subscriptions of this type renew automatically by default. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[default_billing_interval] (string, optional): Interval new subscriptions of this type are billed at by default. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subscription-types' \ --data-urlencode 'filter[active]=true' \ --data-urlencode 'sort=sort_order' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/subscription-types Create a subscription type - MCP id: subscription-types.store - Scope: api:write:subscription_types - Permission: subscription_types::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a type and returns it in the detail view with a `Location` header. `sort_order` is assigned by the server as one past the highest existing position and cannot be sent. Names are not unique, so two types may share one. Template lines are not accepted here: create them one request each through POST `subscription-types/{subscription_type}/items`, so sending items is a `422` `validation-failed` for an unknown key. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, optional): What subscriptions of this type cover, as free text. - default_billing_interval (string, optional): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, optional): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, optional): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/subscription-types' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Hosting retainer", "description": "Managed hosting and monitoring, billed monthly.", "default_billing_interval": "monthly", "auto_renew": true, "active": true }' ``` --- # GET /api/v2/subscription-types/{subscription_type} Read a subscription type - MCP id: subscription-types.show - Scope: api:read:subscription_types - Permission: subscription_types::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one type in the detail view. Types are soft-deleted, so a deleted type is `404` unless `trashed=with` or `trashed=only` is passed; `trashed=only` 404s a live type. Its template lines are reached through the items link, never through `?include=`. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/subscription-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/subscription-types/{subscription_type} Update a subscription type - MCP id: subscription-types.update - Scope: api:write:subscription_types - Permission: subscription_types::update - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a type: absent keys are untouched. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Renaming a type also renames what its existing customer subscriptions read as: a subscription's displayed name is its type's name, resolved at read time rather than copied. Existing subscriptions keep the billing interval and renewal flag they were created with; a change here applies to subscriptions created afterwards. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, optional): What subscriptions of this type cover, as free text. - default_billing_interval (string, optional): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, optional): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, optional): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/subscription-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "active": false }' ``` --- # DELETE /api/v2/subscription-types/{subscription_type} Delete a subscription type - MCP id: subscription-types.destroy - Scope: api:destroy:subscription_types - Permission: subscription_types::delete - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Soft-deletes a type: it is only marked deleted, and POST `subscription-types/{subscription_type}/actions/restore` reverses it. There is no permanent delete. Nothing else is deleted and nothing is cleaned up: existing subscriptions keep pointing at a type that has vanished from the catalogue and from `?include=subscription_type` reads, and their displayed name goes with it. The type's own template lines are not deleted either, for the same reason. Re-deleting an already-deleted type answers `204`, so a retry is safe. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/subscription-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/subscription-types/{subscription_type}/actions/restore Restore a deleted subscription type - MCP id: subscription-types.actions.restore - Scope: api:write:subscription_types - Permission: subscription_types::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Clears the soft-delete marker and returns the type in the detail view, which puts it back in the catalogue and gives its existing customer subscriptions their displayed name back. Restoring additionally requires `subscription_types::delete` as well as `subscription_types::create`. Restoring a type that is not deleted succeeds and returns an `already_in_target_state` warning. The restore finds a deleted type on its own, so the trashed parameter is not accepted. The type's template lines were never deleted, so nothing about them changes. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/subscription-types/1/actions/restore' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{}' ``` --- # GET /api/v2/subscription-types/{subscription_type}/items List a subscription type's template lines - MCP id: subscription-types.items.index - Scope: api:read:subscription_types - Permission: subscription_types::read - Needs the billing feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns one subscription type's template lines in template order, `sort_order` ascending. There is no organisation-wide collection of template lines and no `filter[subscription_type_id]`: a line only means anything under the type it belongs to, so this nested path is the only way to list them. A deleted type answers `404` for the whole path unless `trashed=with` is passed, which also includes the type's deleted lines. Defaults to the summary view; pass `view=detail` for the full payload, or `include=subscribable` to expand the product each line sells. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[subscribable_id] (integer, optional): Id of the record this template line sells. Send together with `subscribable_type`. Operators: = in null. - filter[subscribable_type] (string, optional): Type of the record this template line sells. Send together with `subscribable_id`. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - subscription_type_id (integer, required, server-assigned): The subscription type this template line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another type. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this template line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. - name (string or null ≤ 255, required): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, required, multiline): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer, required): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp), required): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. - default_sales_price (decimal (2 dp), required): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - sort_order (integer, required, server-assigned): Position of the line within its subscription type, and the default sort key. Assigned on create; there is no way to reorder template lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionTypeItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subscription-types/1/items' \ --data-urlencode 'trashed=with' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/subscription-types/{subscription_type}/items Add a template line to a subscription type - MCP id: subscription-types.items.store - Scope: api:write:subscription_types - Permission: subscription_types::create - Needs the billing feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one template line on the subscription type named in the path and returns it in the detail view, with a `Location` header pointing at the flat item path. The type comes from the path, so `subscription_type_id` is not accepted in the body and a line can never be moved between types. Every field is optional: a POST with an empty body creates a blank line, exactly as plnnrly's form does when a line is added. `subscribable_type` and `subscribable_id` are all-or-nothing and the only accepted type is `subscription_product`: a line can be pointed at a hosting domain or server in plnnrly, and a GET will report that, but not through this API. Nothing on the subscription type itself is recalculated: a type is a blueprint and has no totals. A deleted type answers `404`. ## Parameters - subscription_type (integer, path): The SubscriptionType's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string or null ≤ 255, optional): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, optional): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer ≥ 1, optional): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp) -99999999.99 to 99999999.99, optional): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. At least -99999999.99. At most 99999999.99. - default_sales_price (decimal (2 dp) -99999999.99 to 99999999.99, optional): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. At least -99999999.99. At most 99999999.99. - vat_rate (integer 0 to 100, optional): VAT percentage as a whole number: 21 means 21%. - subscribable_type (string or null, optional): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. Required when subscribable_id is present. - subscribable_id (integer or null → SubscriptionProduct.id, optional): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. Required when subscribable_type is present. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - subscription_type_id (integer, required, server-assigned): The subscription type this template line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another type. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this template line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. - name (string or null ≤ 255, required): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, required, multiline): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer, required): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp), required): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. - default_sales_price (decimal (2 dp), required): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - sort_order (integer, required, server-assigned): Position of the line within its subscription type, and the default sort key. Assigned on create; there is no way to reorder template lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionTypeItemLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/subscription-types/1/items' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Managed hosting", "description": "Hosting for {{$domain}} over {{$period}}.", "quantity": 1, "default_purchase_price": "12.50", "default_sales_price": "29.95", "subscribable_type": "subscription_product", "subscribable_id": 1 }' ``` --- # GET /api/v2/subtasks/{subtask} Read a subtask - MCP id: subtasks.show - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one checklist subtask in the detail view, including its rich-text `description`. Subtasks are hard-deleted, so a deleted one is simply 404 and `trashed` is not an accepted parameter. Reading additionally requires `project_management::manage_work_items` on the parent work item. ## Parameters - subtask (integer, path): The Subtask's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - work_item_id (integer, required, server-assigned): The work item whose checklist this subtask belongs to. Taken from the path on create and not writable. - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (html or null, required, detail only): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, required): Whether the subtask is ticked off. - order (integer, required, server-assigned): Position in the work item's checklist. Assigned on create as the next position in that checklist, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - work_item (Ref or WorkItemSummary, required): The work item this subtask hangs off. - links (SubtaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/subtasks/1' \ --data-urlencode 'include=work_item' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/subtasks/{subtask} Update a subtask - MCP id: subtasks.update - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one subtask: absent keys are untouched. This covers both plnnrly's edit action (title and description) and its tick-off toggle (`is_resolved`); there is no actions/resolve action. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`; a subtask has no `version` field, so its `ETag` is derived from `updated_at`, and the `ETag` in the response is the one to send next. `work_item_id` and order are not accepted: a subtask cannot be moved to another work item, and there is no way to reorder a checklist through the API. Note the API is stricter than plnnrly here: plnnrly lets a user merely assigned to a work item tick its subtasks, while this endpoint requires `project_management::manage_work_items`. ## Parameters - subtask (integer, path): The Subtask's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (string or null, optional): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, optional): Whether the subtask is ticked off. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - work_item_id (integer, required, server-assigned): The work item whose checklist this subtask belongs to. Taken from the path on create and not writable. - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (html or null, required, detail only): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, required): Whether the subtask is ticked off. - order (integer, required, server-assigned): Position in the work item's checklist. Assigned on create as the next position in that checklist, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - work_item (Ref or WorkItemSummary, required): The work item this subtask hangs off. - links (SubtaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/subtasks/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "is_resolved": true }' ``` --- # DELETE /api/v2/subtasks/{subtask} Delete a subtask - MCP id: subtasks.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes one checklist subtask and answers `204`. There is no soft delete, no ?force parameter and no restore verb: a subtask comes back only by being re-created through `POST work-items/{work_item}/subtasks`, and it is appended to the end of the checklist rather than to its old position. Nothing else is deleted, except that any files attached to the subtask in plnnrly are removed with it. ## Parameters - subtask (integer, path): The Subtask's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/subtasks/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/tasks/{task} Read a task - MCP id: tasks.show - Scope: api:read:kanban - Permission: kanban::manage_self - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one kanban task with its budget and the employee it belongs to. Tasks are per-user: a caller sees only their own unless they hold `kanban::manage_for_others`, and an id outside that scope is `404` rather than `403`, so the endpoint cannot be walked to discover a colleague's cards. Tasks are hard-deleted, so a deleted one is simply 404 and `trashed` is not an accepted parameter. The task's project is not in the payload (a task has no `project_id` of its own, and the budget object carries only id and description), so a client that needs the project follows `GET /api/v2/budgets/{budget}`. The summary and detail views are identical on this resource. ## Parameters - task (integer, path): The Task's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - budget_id (integer, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - user_id (integer or null, required, server-assigned): The employee the task belongs to. Always the authenticated user on create; not writable. Null on a task left unassigned in plnnrly. - team_id (integer or null, required, server-assigned): Team the task is filed under. Nothing on the API path assigns it, so a task created through the API always carries `null`; plnnrly itself is the only place a team is set. Not writable, and there is no `team` object beside it. - due_date (date (YYYY-MM-DD) or null, required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, required): Optional longer comment on the card, capped at 255 characters. - is_finished (boolean, required): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, required): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. - status (string, required): Kanban column the card sits in. - order_column (integer, required, server-assigned): Position on the kanban board, lowest first. Appended to the end of the board on create and changed when someone drags the card in plnnrly. Not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budget (Ref or BudgetSummary, required): The budget this task is filed under. - user (Ref or UserSummary or null, required): The employee the task belongs to. - links (TaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/tasks/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/tasks/{task} Update a task - MCP id: tasks.update - Scope: api:write:kanban - Permission: kanban::manage_self - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a task: absent keys are untouched and an empty body is rejected. A status change is a plain PATCH, not an action. Note that the response body can differ from the request body: `status` and `is_finished` are cross-synced on every write, so sending `is_finished`: `true` answers status: "finished", moving `status` off finished answers `is_finished`: `false`, and re-opening a finished task without naming a status answers status: "active". `status` wins when both are sent. `budget_id` is accepted at create time only, so a task cannot be re-budgeted or moved to another project here; `user_id`, `team_id` and `order_column` are not writable at all, which means a task cannot be reassigned to another employee through the API and there is no way to reorder a board. The write also bumps the owning project's `updated_at`, so the project's `ETag` changes and it reappears in an incremental `filter[updated_at][gte]` window, and it lands in that project's activity timeline rather than a timeline of its own. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. ## Parameters - task (integer, path): The Task's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - due_date (date (YYYY-MM-DD) or null, optional): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer ≥ 0, optional): Estimated effort, in minutes. - description (string ≤ 255, optional): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, optional): Optional longer comment on the card, capped at 255 characters. - status (string, optional): Kanban column the card sits in. - is_finished (boolean, optional): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, optional): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - budget_id (integer, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - user_id (integer or null, required, server-assigned): The employee the task belongs to. Always the authenticated user on create; not writable. Null on a task left unassigned in plnnrly. - team_id (integer or null, required, server-assigned): Team the task is filed under. Nothing on the API path assigns it, so a task created through the API always carries `null`; plnnrly itself is the only place a team is set. Not writable, and there is no `team` object beside it. - due_date (date (YYYY-MM-DD) or null, required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, required): Optional longer comment on the card, capped at 255 characters. - is_finished (boolean, required): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, required): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. - status (string, required): Kanban column the card sits in. - order_column (integer, required, server-assigned): Position on the kanban board, lowest first. Appended to the end of the board on create and changed when someone drags the card in plnnrly. Not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budget (Ref or BudgetSummary, required): The budget this task is filed under. - user (Ref or UserSummary or null, required): The employee the task belongs to. - links (TaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/tasks/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "status": "finished" }' ``` --- # DELETE /api/v2/tasks/{task} Delete a task - MCP id: tasks.destroy - Scope: api:destroy:kanban - Permission: kanban::manage_self - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Deletes a task permanently and answers `204`: it uses no soft deletes, so there is no restore verb and no force parameter, and a deleted task comes back only by being re-created through `POST projects/{project}/tasks`, appended to the end of the board rather than to its old position. A task carrying logged time is deleted without complaint: there is no guard on this operation. Nothing is deleted with it either: worklogs, planning events, Outlook events and work items that pointed at the task survive and are detached, their task reference set to `null`. The delete is recorded in the owning project's activity timeline rather than a timeline of its own, but unlike an update it does not bump the project's `updated_at`, so an incremental `filter[updated_at][gte]` window on projects will not show it. ## Parameters - task (integer, path): The Task's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/tasks/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/timers List running timers - MCP id: timers.index - Scope: api:read:hours - Permission: hours::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1. - Cache-Control: private, no-store Returns the running timers, newest first by `created_at`. A caller sees only their own rows unless they hold `times::update_others`, and that narrowing is applied before any client filter, before pagination and before the count. `meta.page.total` is always present, so `with_total` is not an accepted parameter. Neither is `q` (a timer has no text to search), nor `meta`, `trashed` or `force`; sending any of them is a `400`. To find the timer on one record, send the type and id together: `filter[timeable_type]=work_item&filter[timeable_id]=41`; a lone half is a `400`. A timer on a record type this API does not publish (an Outlook calendar event, which the agenda really does time) is returned with `timeable_type` set to `outlook_event` and `timeable` as the bare `{"id": N}` reference form, which `?include=timeable` cannot expand. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[started_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the stopwatch was started. Operators: = gte lte. - filter[timeable_id] (integer, optional): Id of the timed record. Send together with `timeable_type`. Operators: = in. - filter[timeable_type] (string, optional): Type of the timed record. Send together with `timeable_id`. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose timers to return. Accepts the literal `me`, and defaults to the authenticated user. Naming another employee requires the `times::update_others` permission. Operators: = in. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this timer belongs to. Always the authenticated user; a timer cannot be started for someone else. - user (Ref or UserSummary, required): The employee this timer belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - timeable_type (string, required): The kind of record `timeable` points at; it says which branch of `timeable` applies. - timeable_id (integer, required): The id of the record `timeable` points at. - timeable (Ref or WorkItemSummary or EventSummary, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimerLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/timers' \ --data-urlencode 'filter[user_id]=me' \ --data-urlencode 'include=timeable' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/timers Start a timer - MCP id: timers.store - Scope: api:write:hours - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Starts a stopwatch on a work item or a planning event for the authenticated user and returns it with a `Location` header. `user_id` is not accepted: a timer always belongs to the caller, and there is no way to start one for another employee. Send `timeable_type` and `timeable_id` together; omit `started_at` to start now. A second timer on the same record for the same caller is a `422` with code `unique` (there is at most one timer per employee per record), so `Idempotency-Key` is optional here rather than required: a retry meets that rule anyway. There is no stop action: to book the elapsed minutes, read `started_at`, POST the hours to `/api/v2/worklogs` and then DELETE this timer. Those two calls are not atomic: a client that deletes first loses the minutes, and one that crashes between them leaves the timer running. This operation declares no permission of its own, and a caller can only ever create their own timer. plnnrly also hides its Start button on a record you may not log time on; the API does not check that when the timer starts. Nothing unbillable can be booked that way regardless: `POST /api/v2/worklogs` applies that gate when the hours are actually written. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - timeable_type (string, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - timeable_id (integer → WorkItem.id, optional, unique): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. Required when timeable_type is present. Must be unique across Timer. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this timer belongs to. Always the authenticated user; a timer cannot be started for someone else. - user (Ref or UserSummary, required): The employee this timer belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - timeable_type (string, required): The kind of record `timeable` points at; it says which branch of `timeable` applies. - timeable_id (integer, required): The id of the record `timeable` points at. - timeable (Ref or WorkItemSummary or EventSummary, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimerLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/timers' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "timeable_type": "work_item", "timeable_id": 41 }' ``` --- # GET /api/v2/timers/{timer} Read a timer - MCP id: timers.show - Scope: api:read:hours - Permission: hours::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one timer. The summary and detail views are identical on this resource: every field is small enough for a list row. A timer belonging to another employee is `404` unless the caller holds `times::update_others`; the row is invisible, so its existence stays invisible. `user` and `timeable` render as `{"id": N}` references; `?include=timeable,user` expands them, and each expansion additionally requires the read permission and token scope of its own target prefix. A timer on an Outlook calendar event keeps the bare `{"id": N}` form with `timeable_type` intact even under `?include=timeable`, because that record type is not published by this API. Elapsed time is derived from `started_at` by the client; the API publishes no elapsed counter. ## Parameters - timer (integer, path): The Timer's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this timer belongs to. Always the authenticated user; a timer cannot be started for someone else. - user (Ref or UserSummary, required): The employee this timer belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - timeable_type (string, required): The kind of record `timeable` points at; it says which branch of `timeable` applies. - timeable_id (integer, required): The id of the record `timeable` points at. - timeable (Ref or WorkItemSummary or EventSummary, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimerLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/timers/1' \ --data-urlencode 'include=timeable,user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/timers/{timer} Correct a timer - MCP id: timers.update - Scope: api:write:hours - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a timer: absent keys are untouched, an empty body is rejected. `started_at` is the only writable key; correcting a mis-started stopwatch is the whole purpose. The `timeable_type` / `timeable_id` pair is never accepted on a PATCH, so retargeting a timer is a DELETE followed by a POST. `user_id` is not accepted either, so a timer cannot be moved to another employee. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. This operation declares no permission of its own: you may change your own timers, and changing another employee's timer additionally requires `times::update_others`. ## Parameters - timer (integer, path): The Timer's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this timer belongs to. Always the authenticated user; a timer cannot be started for someone else. - user (Ref or UserSummary, required): The employee this timer belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - timeable_type (string, required): The kind of record `timeable` points at; it says which branch of `timeable` applies. - timeable_id (integer, required): The id of the record `timeable` points at. - timeable (Ref or WorkItemSummary or EventSummary, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimerLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/timers/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "started_at": "2026-09-01T08:45:00Z" }' ``` --- # DELETE /api/v2/timers/{timer} Discard a timer - MCP id: timers.destroy - Scope: api:destroy:hours - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a timer, like discarding it in plnnrly. This resource is hard-delete: there is no restore action, no `force` parameter, no `trashed` parameter and no deletion signal for a sync client. Nothing blocks the delete, and the elapsed minutes are simply lost: to keep them, POST the hours to `/api/v2/worklogs` first and delete the timer afterwards. Those two calls are not atomic: deleting first loses the minutes, and crashing between them leaves a stale timer running. This operation declares no permission of its own: you may delete your own timers, and deleting another employee's timer additionally requires `times::update_others`. ## Parameters - timer (integer, path): The Timer's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/timers/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/todos List your todos - MCP id: todos.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1, `q` +2. - Cache-Control: private, no-store Returns your todos (the unchecked task-list lines in drive documents that mention you), newest first. This is the list behind the Todos dashboard in plnnrly. The collection is hard-scoped to the authenticated caller with no escape: there is no permission that widens it, `filter[user_id]` accepts `me` and nothing else, and no include reaches another user's rows. ⚠ Rows are auto-derived from document content: every write to a drive item deletes the whole todo set for that item and rebuilds it from the document HTML, so ids are not stable across an edit of a source document. `?q=` substring-matches `todo_content`, and `filter[drive_item_id]` narrows to particular documents. `total` is omitted unless you pass `with_total=1`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[drive_item_id] (integer, optional): Limit to todos parsed out of these drive items. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose todos to return. Accepts the literal `me`, and `me` is the only value any caller may name: this collection has no permission that grants access to another user's todos. Operators: = in. - q (string, optional): Free-text search over `todo_content`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The user the todo belongs to. Always the authenticated caller: this collection has no way to read or write another user's todos. - drive_item_id (integer, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string, required, multiline): The todo line as plain text. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - user (Ref or UserSummary, required): The user the todo belongs to, always the authenticated caller. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - links (UserTodoLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/todos' \ --data-urlencode 'filter[drive_item_id]=1' \ --data-urlencode 'include=user' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/todos Create a todo - MCP id: todos.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a todo for you against a drive item and returns it with a `Location` header. `user_id` is always the authenticated caller and cannot be set: there is no way to create a todo for someone else. A drive item you cannot read, or one that is archived, is refused exactly as a nonexistent one is. ⚠ This write is temporary by construction. Todos are a derived copy of document content: the next write to the source drive item (including a title-only `PATCH /api/v2/drive-items/{drive_item}`) deletes every todo for that item and rebuilds the set from the document HTML, so a todo created here disappears the next time its document is touched. To create a todo that lasts, add an unchecked task item mentioning the user to the document itself. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - drive_item_id (integer → DriveItem.id, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string ≤ 65535, required): The todo line as plain text. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The user the todo belongs to. Always the authenticated caller: this collection has no way to read or write another user's todos. - drive_item_id (integer, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string, required, multiline): The todo line as plain text. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - user (Ref or UserSummary, required): The user the todo belongs to, always the authenticated caller. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - links (UserTodoLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/todos' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "drive_item_id": 1, "todo_content": "Confirm the kick-off date with the client" }' ``` --- # GET /api/v2/todos/{todo} Read a todo - MCP id: todos.show - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one of your todos in the detail view. The summary and detail payloads are identical on this resource: `todo_content` is rendered in both, because the collection is the surface this resource exists for. `user` renders as {"id": N} until named in include, and no relation object is exposed for the source document: read it with GET `/api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Another user's todo answers `404`, never `403`. There is no soft delete: a deleted todo is gone, and `trashed` is not an accepted parameter. ## Parameters - todo (integer, path): The UserTodo's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The user the todo belongs to. Always the authenticated caller: this collection has no way to read or write another user's todos. - drive_item_id (integer, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string, required, multiline): The todo line as plain text. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - user (Ref or UserSummary, required): The user the todo belongs to, always the authenticated caller. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - links (UserTodoLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/todos/1' \ --data-urlencode 'include=user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/todos/{todo} Update a todo - MCP id: todos.update - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates one of your todos: absent keys are untouched, an empty body is rejected. `todo_content` is the only writable key: a todo cannot be re-parented, so `drive_item_id` is not accepted, and `user_id` is never writable. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ⚠ This write is temporary by construction. Todos are a derived copy of document content: the next write to the source drive item (including a title-only `PATCH /api/v2/drive-items/{drive_item}`) deletes every todo for that item and rebuilds the set from the document HTML, so this edit is discarded the next time the document is touched. To change a todo's text durably, edit the task item in the document itself. ## Parameters - todo (integer, path): The UserTodo's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - todo_content (string ≤ 65535, optional): The todo line as plain text. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The user the todo belongs to. Always the authenticated caller: this collection has no way to read or write another user's todos. - drive_item_id (integer, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string, required, multiline): The todo line as plain text. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - user (Ref or UserSummary, required): The user the todo belongs to, always the authenticated caller. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - links (UserTodoLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/todos/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "todo_content": "Confirm the kick-off date with the client before Friday" }' ``` --- # DELETE /api/v2/todos/{todo} Delete a todo - MCP id: todos.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes one of your todos. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Deleting an already-deleted todo answers `404`, and the source document is unaffected. ⚠ The DELETE is cosmetic and temporary. Todos are a derived copy of document content: the todo comes back the next time the source drive item is written and its todo set rebuilt, for as long as the unchecked task item mentioning you is still in the document HTML. To make a todo go away for good, check the task item off in the document, or remove the mention. ## Parameters - todo (integer, path): The UserTodo's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/todos/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/uploads/{upload} Read an upload ticket - MCP id: uploads.show - Scope: api:read:project_management - Scope: api:read:project_drive - Scope: api:read:customer_drive - Scope: api:read:organization_drive - Scope: api:read:user_drive - Permission: none, the record's own rules still apply - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one of your own upload tickets: its status, the size and SHA-256 of the bytes the server actually received, the media type it detected from them, both deadlines and, while the ticket is still waiting for its bytes, the `upload_url` to PUT them to. Use it to check whether an upload landed before registering it, or to recover the `media_id` of one already registered. A ticket belongs to the credential that created it: somebody else's ticket is `404`, and so is one whose permission family your token does not cover. No domain permission is required (the ticket is your own scratch object), but the parent record's write permission was checked when it was created and is checked again when it is registered. Tickets are hard-deleted, so `trashed` is not an accepted parameter, and a pruned one is `404`. ## Parameters - upload (integer, path): The Upload's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. Send this as `upload_id` when you register the bytes. - work_item_id (integer or null, required): The work item this ticket attaches to, or `null` when it targets a drive item. - drive_item_id (integer or null, required): The drive item this ticket attaches to, or `null` when it targets a work item. - collection (string ≤ 255, required): The media collection the file will land in. Always `attachments`: it is chosen by the route, and no request field can change it. - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null, required): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. - received_byte_size (integer or null, required): The number of bytes that actually arrived, measured by the server. Null until the upload lands. - received_checksum_sha256 (string or null, required): The SHA-256 the server computed over the bytes it received. Null until the upload lands. - mime_type (string or null ≤ 255, required): The media type detected from the received bytes. Detected, never declared: the API accepts no content type when creating the ticket and ignores the `Content-Type` of the upload itself. - status (string, required): Where the ticket is in its lifecycle. - upload_url (string or null, required): Where to PUT the file. An opaque absolute URL: send the raw file as the body of a PUT whose only header is your Authorization bearer token. No `Content-Type` is read and the body is stored exactly as sent, so a form envelope would become part of the file. Present only while status is `pending`. - expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): The deadline for the PUT. After it the ticket is dead and a new one must be created. - register_by (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): The deadline for the registration, set when the bytes land and measured from that moment, so a slow upload does not eat into the time you have to register. Null until the upload lands. - uploaded_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes arrived. Null until they do. - committed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes became a file. Null until they do. - media_id (integer or null, required): The id of the file this ticket became. Null until it is registered, and set exactly once. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the ticket was created. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (UploadLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/uploads/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # DELETE /api/v2/uploads/{upload} Delete an upload ticket - MCP id: uploads.destroy - Scope: api:destroy:project_management - Scope: api:destroy:project_drive - Scope: api:destroy:customer_drive - Scope: api:destroy:organization_drive - Scope: api:destroy:user_drive - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Revokes one of your own unregistered upload tickets: the staged bytes are deleted from storage, the ticket is marked revoked and answers `204`. This is the escape hatch for a ticket whose `upload_url` leaked, and it is why a 30-minute window is acceptable rather than merely short. A ticket that has already become a file is `409` `upload_already_registered`: the bytes are an attachment now, and this API does not delete files; remove one in plnnrly. Somebody else's ticket is `404`, as is one whose permission family your token does not cover. No domain permission is required. There is no soft delete, no ?force and no restore verb. ## Parameters - upload (integer, path): The Upload's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/uploads/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/users List users - MCP id: users.index - Scope: api:read:users - Permission: users::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's people, by name A→Z, the order plnnrly's user list uses. Search by name or email address with `q`, which is the practical way to resolve a colleague to the `user_id` every other collection filters on. Service accounts are never listed. Roles, permissions and teams are not part of this payload and no filter narrows by them. The working week is a detail-view field: send `view=detail` to get `working_hours`. `?include=` is not accepted: this resource expands no relation, and `company_branch` in particular belongs to customers, not to users. Soft-deleted users are excluded by default; `trashed=with` includes them and `trashed=only` returns just the departed, which is how an incremental sync learns somebody left. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[birthday] (date (YYYY-MM-DD), optional): Date of birth. `null` selects the users whose birthday was never recorded. Operators: = gte lte null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[date_employed] (date (YYYY-MM-DD), optional): First day of employment. `null` selects the users whose start date was never recorded. Operators: = gte lte null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`, `email`, in that order of relevance. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Full name as shown throughout plnnrly. Searchable with `?q=`. - email (string ≤ 255, required): Work email address, unique within the organisation. Searchable with `?q=`, which is the practical way to resolve a person to an id. - color (string ≤ 255, required): The user's identifying colour, as a CSS colour string. Used by plnnrly's calendars and avatars; render it or ignore it. - job_title (string ≤ 255, required): Job title, e.g. Project Manager. - phone_number (string or null ≤ 255, required): Phone number as entered, in no normalised format. - timezone (string or null ≤ 255, required): The user's own IANA timezone, e.g. Europe/Amsterdam. Null means the organisation's default applies. Every timestamp in this API is UTC regardless; this is the zone a client renders them in for this person. - date_employed (date (YYYY-MM-DD) or null, required): First day of employment, or `null` where it was never recorded. A calendar date with no clock, so it is never converted to UTC. - birthday (date (YYYY-MM-DD) or null, required): Date of birth, or `null` where it was never recorded. - profitability_target_percentage (integer or null, required): Share of available hours this person is expected to bill, 1 to 100, or `null` where no target is set. Read by the profitability report; a user without a target is still reported on, just not judged against one. - working_hours (array of object or null, required, detail only): The contracted working week, one entry per shift, in week order. A weekday may carry several entries (a split day is two ranges), and a weekday that is not worked simply has none. Null or empty means no schedule has been recorded. - work_locations (object, required, detail only): Where the user usually works on each weekday, keyed monday through sunday. The stored pattern lists only the days away from the office, so a weekday it does not name reads as office here. A single day's override from the agenda is not reflected. - monday (string, required): Monday. - tuesday (string, required): Tuesday. - wednesday (string, required): Wednesday. - thursday (string, required): Thursday. - friday (string, required): Friday. - saturday (string, required): Saturday. - sunday (string, required): Sunday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while the user is active. - links (UserLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/users' \ --data-urlencode 'q=Jansen' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/users/{user} Read a user - MCP id: users.show - Scope: api:read:users - Permission: users::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one user. The detail view adds `working_hours`; everything else is in both views. A caller without `users::read` gets `404`, never `403`: a `403` on an id confirms the id exists, so the two answers would enumerate the organisation's people. A service account is `404` for the same reason it is absent from the list. A soft-deleted user is `404` unless you pass `trashed=with`; `trashed=only` returns a departed user and 404s a current one, which makes it an existence probe for a sync client and nothing else. There is no restore action on this resource, and reading a trashed row needs only `users::read`. ## Parameters - user (integer, path): The User's numeric id. Accepts a soft-deleted record. - view (string, optional): Which field set to render. - trashed (string, optional): Whether soft-deleted rows are included. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Full name as shown throughout plnnrly. Searchable with `?q=`. - email (string ≤ 255, required): Work email address, unique within the organisation. Searchable with `?q=`, which is the practical way to resolve a person to an id. - color (string ≤ 255, required): The user's identifying colour, as a CSS colour string. Used by plnnrly's calendars and avatars; render it or ignore it. - job_title (string ≤ 255, required): Job title, e.g. Project Manager. - phone_number (string or null ≤ 255, required): Phone number as entered, in no normalised format. - timezone (string or null ≤ 255, required): The user's own IANA timezone, e.g. Europe/Amsterdam. Null means the organisation's default applies. Every timestamp in this API is UTC regardless; this is the zone a client renders them in for this person. - date_employed (date (YYYY-MM-DD) or null, required): First day of employment, or `null` where it was never recorded. A calendar date with no clock, so it is never converted to UTC. - birthday (date (YYYY-MM-DD) or null, required): Date of birth, or `null` where it was never recorded. - profitability_target_percentage (integer or null, required): Share of available hours this person is expected to bill, 1 to 100, or `null` where no target is set. Read by the profitability report; a user without a target is still reported on, just not judged against one. - working_hours (array of object or null, required, detail only): The contracted working week, one entry per shift, in week order. A weekday may carry several entries (a split day is two ranges), and a weekday that is not worked simply has none. Null or empty means no schedule has been recorded. - work_locations (object, required, detail only): Where the user usually works on each weekday, keyed monday through sunday. The stored pattern lists only the days away from the office, so a weekday it does not name reads as office here. A single day's override from the agenda is not reflected. - monday (string, required): Monday. - tuesday (string, required): Tuesday. - wednesday (string, required): Wednesday. - thursday (string, required): Thursday. - friday (string, required): Friday. - saturday (string, required): Saturday. - sunday (string, required): Sunday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while the user is active. - links (UserLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/users/1' \ --data-urlencode 'view=detail' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/work-item-links/{work_item_link} Read a work item link - MCP id: work-item-links.show - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one link on its own terms. There is no work item in this path, so `direction` is always `forward` and `relationship` is the forward label; read the link through `work-items/{work_item}/links` to see it from one end's point of view. The summary and detail views are identical on this resource. Links are hard-deleted, and deleting either work item deletes the link with it, so a gone link is simply 404 and `trashed` is not an accepted parameter. Reading additionally requires `project_management::manage_work_items` on the source work item. ## Parameters - work_item_link (integer, path): The WorkItemLink's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - source_work_item_id (integer, required): Work item the link points away from. On a link created through `POST work-items/{work_item}/links` this is always the work item named in the path. - target_work_item_id (integer, required): Work item the link points at. Always in the same project as the source. - link_type (string, required): The relationship, stored from the source item's point of view: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. Only `relates_to` is symmetric. - created_by (integer or null, required, server-assigned): User who created the link, or `null` where that account has since been deleted. Resolve it with `GET /api/v2/users/{user}`; it is not includable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - direction (string, required): Which way this link points relative to the work item you asked about: `forward` when that item is the source, `inverse` when it is the target. On the flat `GET work-item-links/{work_item_link}` there is no work item in the path, so the link is read on its own terms and this is always `forward`. - relationship (string, required): The relationship in words, from the point of view of the work item you asked about: `blocks` from the source end, `is blocked by` from the target end. Always in English: the API pins the locale. - source_work_item (Ref or WorkItemSummary, required): Work item the link points away from. - target_work_item (Ref or WorkItemSummary, required): Work item the link points at. - links (WorkItemLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-item-links/1' \ --data-urlencode 'include=source_work_item,target_work_item' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # DELETE /api/v2/work-item-links/{work_item_link} Delete a work item link - MCP id: work-item-links.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently unlinks two work items and answers `204`, like the "remove linked item" action in plnnrly. There is no soft delete, no ?force parameter and no restore verb, and there is no PATCH either: to change a relationship, DELETE the link and POST a new one to `work-items/{work_item}/links`. Neither work item is otherwise affected. Links are hard-deleted and leave no tombstone, so retrying a delete that already succeeded answers `404` rather than `204`; treat that as success. ## Parameters - work_item_link (integer, path): The WorkItemLink's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/work-item-links/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/work-items List work items - MCP id: work-items.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1 (to-many +3), `q` +2. - Cache-Control: private, no-store Returns the work items the caller may read, in each sprint's display order with the id as the tiebreaker. The list covers the whole organisation and interleaves projects, because `order` is unique within a sprint only; narrow it with `filter[project_id]`, and add `filter[sprint_id][null]=true` for a project's backlog or `filter[resolved]=false` for its open work. Pagination is cursor-only: a backlog drag rewrites `order` on the rows behind you, so an offset page would skip and repeat items. `meta.page.total` is `null` unless you pass `with_total=1`, which costs two rate units. Listing additionally requires `project_management::manage_work_items`. Defaults to the summary view, which omits `description`; pass `view=detail` for the full payload. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[assignee_id] (integer, optional): User the work is assigned to. Accepts `me`. Pass `[null]` for unassigned items. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[drive_items.id] (integer, optional): Work items linked to a given drive document. The document must be one the caller may open; a document they cannot open matches nothing. - filter[due_date] (date (YYYY-MM-DD), optional): Due date, a zone-free calendar date. Combine `[gte]` and `[lte]` for a period; pass `[null]` for items with no due date. Operators: = gte lte null. - filter[labels.id] (integer, optional): Work items carrying a given label. - filter[parent_id] (integer, optional): Parent work item. Pass a key for one item's children (sub-issues), or `[null]` for top-level items only. Operators: = in null. - filter[priority] (string, optional): Relative priority. One of highest, high, normal, low, lowest; there is no medium. Operators: = ne in. - filter[project_id] (integer, optional): Project the work item belongs to. This is how a client reproduces one project's backlog; combine it with the other filters to hit `idx_work_items_project_sprint_order` and its siblings. Operators: = in. - filter[reporter_id] (integer, optional): User who filed the work item. Accepts `me`. Operators: = in. - filter[resolved] (boolean, optional): Whether the item sits in a workflow status marked as a resolution. Pass `false` for open work. - filter[sprint_id] (integer, optional): Sprint the item sits in. Pass `[null]` for the backlog, the items in no sprint. Operators: = in null. - filter[task_id] (integer, optional): Kanban task the item is booked against. Pass `[null]` for items booked against no task. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[work_type_id] (integer, optional): Work type of the item. Operators: = in. - filter[workflow_status_id] (integer, optional): Workflow status the item currently sits in. Operators: = in. - q (string, optional): Free-text search over `title`, `issue_key`, in that order of relevance. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items' \ --data-urlencode 'filter[project_id]=1' \ --data-urlencode 'filter[sprint_id][null]=true' \ --data-urlencode 'filter[resolved]=false' \ --data-urlencode 'sort=order' \ --data-urlencode 'with_total=1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items Create a work item - MCP id: work-items.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a work item and returns it in the detail view with a `Location` header. The issue key is generated from the project's customer code and a per-customer sequence; `order` places the item last in its sprint or backlog. `reporter_id` is always the authenticated user: there is no way to file on somebody else's behalf. Omit `workflow_status_id` and `work_type_id` and the server picks both; in an organisation with no work types, or a project whose workflow has no statuses, that is a `409` `state-conflict` with `conflict.reason` unconfigured rather than a validation error about a key you never sent; the fix is an admin action. `label_ids` replaces the label set outright, and `drive_item_ids` the set of linked drive documents; each id must name a document the caller may open, and attaching one additionally requires the `project_drive` prefix's access permission and a token scope covering it. `task_id` must name a task in the project, and an item placed in the running sprint of a sprint project needs one; a simple-mode project does not. A work item cannot be moved between projects afterwards, so pick the project carefully. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - project_id (integer → Project.id, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (string or null, optional): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, optional): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, optional): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null ≥ 0, optional): Original estimate, in minutes. - remaining_estimate (integer or null ≥ 0, optional): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, optional): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. Required when location_latitude, location_longitude is present. - location_address (string or null ≤ 255, optional): Formatted address of the location, when the place has one. Detail view only. - location_latitude (number or null -90 to 90, optional): Latitude of the location in decimal degrees, -90 to 90. Detail view only. Required when location_name, location_longitude is present. - location_longitude (number or null -180 to 180, optional): Longitude of the location in decimal degrees, -180 to 180. Detail view only. Required when location_name, location_latitude is present. - work_type_id (integer or null → WorkType.id, optional): Work type of this item. - workflow_status_id (integer or null → WorkflowStatus.id, optional): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - task_id (integer or null → Task.id, optional): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - assignee_id (integer or null → User.id, optional): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - sprint_id (integer or null → Sprint.id, optional): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null → WorkItem.id, optional): Parent work item, for a sub-issue. Null on a top-level item. - label_ids (array of integer → Label.id ≤ 50, optional): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. - drive_item_ids (array of integer → DriveItem.id ≤ 25, optional): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "project_id": 1, "title": "Portal login rejects SSO users", "priority": "high", "estimation": 240, "label_ids": [ 1 ] }' ``` --- # GET /api/v2/work-items/{work_item} Read a work item - MCP id: work-items.show - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one work item in the detail view, addressed by numeric id or by its issue key: `work-items/41` and `work-items/ACM-123` are the same record. The strong `ETag` is derived from `version`, so `If-None-Match` answers `304` on an unchanged item and the same value works as `If-Match` on a write. Reading additionally requires `project_management::manage_work_items`. Comments, subtasks, links, attachments, child work items, labels and linked drive documents are reached through `data.links`; comments, subtasks, labels and drive documents can also be expanded inline with include, up to 25 of each. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - meta (array of string, optional): Optional meta members to compute for this response. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items/1' \ --data-urlencode 'include=labels,comments' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/work-items/{work_item} Update a work item - MCP id: work-items.update - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a work item: absent keys are untouched, an empty body is rejected. `workflow_status_id` is not accepted here: moving between statuses is validated against the project workflow, so it lives at POST `work-items/{work_item}/actions/transition`. `project_id`, `issue_key`, `order` and `column_order` are not accepted either: a work item cannot change project (its issue key names the project's customer) and its positions change only by dragging in plnnrly. `label_ids` replaces the label set outright and `drive_item_ids` the set of linked drive documents; [] clears either. Each drive item id must name a document the caller may open, which additionally requires the `project_drive` prefix's access permission and a token scope covering it. `task_id` must name a task in the project; sending it makes the task's owner the assignee, and sending `assignee_id` alone on a sprint project moves the item to that user's only open task when there is exactly one. Clearing the task of an item in the running sprint of a sprint project, or moving a task-less item into it, is refused with `422` on `/task_id`; a simple-mode project has no such rule. One PATCH is exactly one write: however many fields the body carries, `version` is bumped once and one update is broadcast to the boards watching this item. A body carrying only `label_ids` or only `drive_item_ids` changes the set without bumping `version`, because those sets are not fields of the work item itself. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412` and reports the current version. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - title (string ≤ 255, optional): Work item title. The other field `?q=` searches. - description (string or null, optional): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, optional): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, optional): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null ≥ 0, optional): Original estimate, in minutes. - remaining_estimate (integer or null ≥ 0, optional): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, optional): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. Required when location_latitude, location_longitude is present. - location_address (string or null ≤ 255, optional): Formatted address of the location, when the place has one. Detail view only. - location_latitude (number or null -90 to 90, optional): Latitude of the location in decimal degrees, -90 to 90. Detail view only. Required when location_name, location_longitude is present. - location_longitude (number or null -180 to 180, optional): Longitude of the location in decimal degrees, -180 to 180. Detail view only. Required when location_name, location_latitude is present. - work_type_id (integer or null → WorkType.id, optional): Work type of this item. - task_id (integer or null → Task.id, optional): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - assignee_id (integer or null → User.id, optional): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - sprint_id (integer or null → Sprint.id, optional): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null → WorkItem.id, optional): Parent work item, for a sub-issue. Null on a top-level item. - label_ids (array of integer → Label.id ≤ 50, optional): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. - drive_item_ids (array of integer → DriveItem.id ≤ 25, optional): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/work-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "assignee_id": 1, "remaining_estimate": 90 }' ``` --- # DELETE /api/v2/work-items/{work_item} Delete a work item - MCP id: work-items.destroy - Scope: api:destroy:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a work item. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Its comments, subtasks, links and attachments go with it. A work item that has child work items is refused with `409` `state-conflict` and `conflict.reason` `has_children`: deleting a parent would silently delete the whole sub-tree. Move or delete the children first. Logged time is untouched: it hangs off the task, not the work item. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/work-items/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items/{work_item}/actions/move Move a work item to a position - MCP id: work-items.actions.move - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Places a work item at a position in a list, the way a drag in plnnrly does. `list: board` positions it in the board column of its workflow status and writes `column_order`; `list: backlog` positions it in its sprint, or in the backlog when it is in no sprint, and writes `order`. The same call can change the status (`workflow_status_id`, validated against the project workflow like POST …/actions/transition, so an unreachable status is `409` `state-conflict` with `conflict.reason` `illegal_transition`) and the sprint (`sprint_id`, `null` for the backlog). `after_work_item_id` names the item to land directly below, by id or issue key; `null` puts the item at the top, and leaving it out keeps the item's place in a list it already sits in, or puts it at the end of a new one. The anchor must be in the target list, otherwise 422 at `/after_work_item_id`. A move to where the item already is writes nothing and leaves `version` as it is, so a retry is safe; any other move bumps `version` once. Positions are not consecutive: other items of the list only shift when there is no room, and keep their `version`. Returns the moved work item in the detail view with a fresh `ETag`. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - list (string, required): Which ordering to write: the board column (`column_order`) or the sprint/backlog list (`order`). - workflow_status_id (integer → WorkflowStatus.id, optional): Status to move the item to. Leave it out to keep the current status. It must be reachable from the current status in the project workflow. - sprint_id (integer or null → Sprint.id, optional): Sprint to move the item to: a not-yet-completed sprint on the item's own project. Null moves it to the backlog. Leave it out to keep the current sprint. - after_work_item_id (integer or string or null, optional): Work item to place this one directly below, by id or issue key. It must be in the target list. Null puts the item at the top. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/actions/move' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "list": "board", "after_work_item_id": 1 }' ``` --- # POST /api/v2/work-items/{work_item}/actions/transition Transition a work item - MCP id: work-items.actions.transition - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Moves a work item to another workflow status. The move is validated against the project workflow: statuses not reachable from the current one are rejected with `409` `state-conflict` and `conflict.reason` `illegal_transition`, naming both ends of the attempted move. For the reachable statuses, send `GET /api/v2/workflow-transitions?filter[from_status_id]={id}` with the item's current `workflow_status_id` as the id; each `to_status_id` is a legal target. Transitioning to the status the item already sits in succeeds and is a no-op, so a retry is safe. Returns the mutated work item in the detail view with a fresh `ETag`, and bumps `version` once. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - workflow_status_id (integer → WorkflowStatus.id, required): Status to move the work item to. It must be reachable from the current status in the project workflow. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/actions/transition' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "workflow_status_id": 1 }' ``` --- # GET /api/v2/work-items/{work_item}/attachments List a work item's attachments - MCP id: work-items.attachments.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns the files attached to one work item, newest upload first, keyset-paginated. There is no organisation-wide collection of files (a file is only ever reached inside its parent's collection), so this nested path is the only way to list a work item's attachments, and `filter[model_type]` / `filter[model_id]` do not exist: the parent is the path segment. The path takes the work item's numeric id or its issue key, so `work-items/ACM-123/attachments` and `work-items/41/attachments` are the same list. Work items are hard-deleted, so `trashed` is not an accepted parameter here. Listing additionally requires `project_management::manage_work_items` on the parent work item. The API never streams bytes: pass `include=url` for a signed download URL per file, and attach a new file with the two-step upload flow that starts at `POST work-items/{work_item}/uploads`. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[collection] (string, optional): Narrow to one media collection. A work item registers only `attachments`; a drive item registers `attachments`, `content` (inline editor images) and `file` (the single uploaded payload), so `filter[collection]=file` is how a client asks a drive item for its payload alone. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[collection][starts] (string, optional): Narrow to one media collection. A work item registers only `attachments`; a drive item registers `attachments`, `content` (inline editor images) and `file` (the single uploaded payload), so `filter[collection]=file` is how a client asks a drive item for its payload alone. One value, matched literally as a prefix: send commas and backslashes as they are, and `%` and `_` match themselves rather than acting as wildcards. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): File name as uploaded. - mime_type (string or null ≤ 255, required): IANA media type. - size (integer, required): Size in bytes. - disk (string ≤ 255, required): Name of the storage location the file is kept in. - collection (string ≤ 255, required): Media collection the file belongs to. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Upload timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - url (string or null, optional, on request): Signed download URL, requiring your bearer token. Request with `?include=url`. Null only where your organisation's file storage cannot sign URLs, in which case the response carries a `signed_url_unavailable` warning and downloads are not available through the API. - url_expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional, on request): When `url` stops working. Always `null` in v2.0: the signed route the URL points at does not expire. Declared so a future bounded-TTL download path is an additive change. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items/1/attachments' \ --data-urlencode 'include=url,url_expires_at' \ --data-urlencode 'per_page=25' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items/{work_item}/attachments Attach an uploaded file to a work item - MCP id: work-items.attachments.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Step 3 of 3. Turns bytes you have already uploaded into an attachment on the work item named in the path, and returns the file in the detail view. `upload_id` is the only key this accepts: create a ticket with `POST work-items/{work_item}/uploads`, PUT the raw bytes to its `upload_url`, then send its id here. Everything about the file (its name, its size, its media type, its collection and its storage location) is read off the ticket and off the bytes that actually arrived, so `file_name`, `mime_type`, `byte_size`, collection and disk are all a `422`. The ticket must have been created for this work item, by this API token, and must not have expired or been registered already; each of those is a specific `409` or `422` naming the cause. The server re-hashes the stored bytes and compares them with what it received before creating anything. This works exactly like an upload in plnnrly, so thumbnails and notifications follow identically. Attaching requires `project_management::manage_work_items`, the same permission plnnrly checks. `Idempotency-Key` is required: a lost 201 retried without one would report a failure for a create that succeeded. There is no `Location` header (a file has no item URL of its own), and no PATCH or DELETE on a file; remove one in plnnrly. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, required): A caller-chosen key that makes this POST safe to retry. Required on this operation: omitting it is 400 idempotency-key-required. ## Request body - upload_id (integer → Upload.id, required): The id of the upload ticket whose bytes become this file. Create one with `POST work-items/{work_item}/uploads` or `POST drive-items/{drive_item}/uploads`, PUT the bytes to its `upload_url`, then send its id here. Write-only: it is never rendered on a media row, and it is the only key either attachment store accepts. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): File name as uploaded. - mime_type (string or null ≤ 255, required): IANA media type. - size (integer, required): Size in bytes. - disk (string ≤ 255, required): Name of the storage location the file is kept in. - collection (string ≤ 255, required): Media collection the file belongs to. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Upload timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - url (string or null, optional, on request): Signed download URL, requiring your bearer token. Request with `?include=url`. Null only where your organisation's file storage cannot sign URLs, in which case the response carries a `signed_url_unavailable` warning and downloads are not available through the API. - url_expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional, on request): When `url` stops working. Always `null` in v2.0: the signed route the URL points at does not expire. Declared so a future bounded-TTL download path is an additive change. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/attachments' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "upload_id": 1 }' ``` --- # GET /api/v2/work-items/{work_item}/comments List a work item's comments - MCP id: work-items.comments.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1. - Cache-Control: private, no-store Returns the comment thread on one work item, newest first, keyset-paginated. There is no organisation-wide collection of comments, so this nested path is the only way to list them and `filter[commentable_type]` / `filter[commentable_id]` do not exist: the parent is the path segment. The path takes the work item's numeric id or its issue key, so `work-items/ACM-123/comments` and `work-items/41/comments` are the same thread. Work items are hard-deleted, so `trashed` is not an accepted parameter here. Listing additionally requires `project_management::manage_work_items` on the parent work item. Pass `include=author` to expand each comment's author; the body is in both views. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[author_id] (integer, optional): Id of the comment's author. Send together with `author_type`. - filter[author_type] (string, optional): Type of the comment's author. `user` is the only value in v2.0. Send together with `author_id`. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items/1/comments' \ --data-urlencode 'include=author' \ --data-urlencode 'per_page=25' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items/{work_item}/comments Post a comment on a work item - MCP id: work-items.comments.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one comment on the work item named in the path, authored as the authenticated user, and returns it in the detail view with a `Location` header pointing at the flat item path. `body` is the only accepted key: the work item comes from the path, so `commentable_type` and `commentable_id` in the body are a `422`, and the author is the caller, so `author_type` and `author_id` are never accepted. The body is rich text: mention spans read from an existing comment may be posted back verbatim and are preserved, but constructing a new mention is not supported in v2.0. Posting works exactly like posting in plnnrly, so mention notifications, thread events and the author's auto-subscription all fire. A deleted work item is `404`. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - body (string ≤ 65535, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/comments' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "body": "

Deployed to staging, ready for review.

" }' ``` --- # GET /api/v2/work-items/{work_item}/links List a work item's links - MCP id: work-items.links.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1. - Cache-Control: private, no-store Returns every link touching one work item, in both directions: the links it is the source of and the links it is the target of, which is what the linked items section in plnnrly shows. `direction` and `relationship` on each row are relative to the work item in the path, so the same link reads `forward` / `blocks` here and `inverse` / `is blocked by` from the other end. The path accepts a numeric id or an issue key, so `work-items/ACM-123/links` works. There is no organisation-wide collection of links and no `filter[source_work_item_id]` or `filter[target_work_item_id]`: the path supplies both. Ordered by id ascending, and `meta.page.total` is always present; `?with_total` is a `400`. Listing additionally requires `project_management::manage_work_items` on the work item. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[created_by] (integer, optional): User who created the link. `[null]` selects links whose creator has since been deleted. Operators: = in null. - filter[link_type] (string, optional): Relationship type: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. The value is stored from the source item's point of view, so it does not flip on an inverse link: filter on `blocks` and you get both the items this one blocks and the items that block it. Operators: = ne in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - source_work_item_id (integer, required): Work item the link points away from. On a link created through `POST work-items/{work_item}/links` this is always the work item named in the path. - target_work_item_id (integer, required): Work item the link points at. Always in the same project as the source. - link_type (string, required): The relationship, stored from the source item's point of view: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. Only `relates_to` is symmetric. - created_by (integer or null, required, server-assigned): User who created the link, or `null` where that account has since been deleted. Resolve it with `GET /api/v2/users/{user}`; it is not includable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - direction (string, required): Which way this link points relative to the work item you asked about: `forward` when that item is the source, `inverse` when it is the target. On the flat `GET work-item-links/{work_item_link}` there is no work item in the path, so the link is read on its own terms and this is always `forward`. - relationship (string, required): The relationship in words, from the point of view of the work item you asked about: `blocks` from the source end, `is blocked by` from the target end. Always in English: the API pins the locale. - source_work_item (Ref or WorkItemSummary, required): Work item the link points away from. - target_work_item (Ref or WorkItemSummary, required): Work item the link points at. - links (WorkItemLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items/1/links' \ --data-urlencode 'filter[link_type]=blocks' \ --data-urlencode 'include=source_work_item,target_work_item' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items/{work_item}/links Link a work item to another - MCP id: work-items.links.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a link from the work item in the path to another work item in the same project, and returns it with a `Location` header pointing at the flat item path. The body is exactly `link_type` and `target_work_item_id`: the source comes from the path, so `source_work_item_id` is not accepted, and `created_by` is the authenticated caller. To create the inverse of a relationship ("this item is blocked by ACM-9"), post to ACM-9's own collection instead: `POST work-items/ACM-9/links` with `link_type=blocks` and this item as the target stores the identical row, which both ends then read from their own side. Three refusals mirror plnnrly: a work item in another project is refused exactly as a nonexistent one is, linking an item to itself is `422` `unprocessable-relationship`, and repeating an existing (source, target, `link_type`) triple is `409` `duplicate-resource`. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - link_type (string, required): The relationship, stored from the source item's point of view: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. Only `relates_to` is symmetric. - target_work_item_id (integer → WorkItem.id, required): Work item the link points at. Always in the same project as the source. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - source_work_item_id (integer, required): Work item the link points away from. On a link created through `POST work-items/{work_item}/links` this is always the work item named in the path. - target_work_item_id (integer, required): Work item the link points at. Always in the same project as the source. - link_type (string, required): The relationship, stored from the source item's point of view: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. Only `relates_to` is symmetric. - created_by (integer or null, required, server-assigned): User who created the link, or `null` where that account has since been deleted. Resolve it with `GET /api/v2/users/{user}`; it is not includable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - direction (string, required): Which way this link points relative to the work item you asked about: `forward` when that item is the source, `inverse` when it is the target. On the flat `GET work-item-links/{work_item_link}` there is no work item in the path, so the link is read on its own terms and this is always `forward`. - relationship (string, required): The relationship in words, from the point of view of the work item you asked about: `blocks` from the source end, `is blocked by` from the target end. Always in English: the API pins the locale. - source_work_item (Ref or WorkItemSummary, required): Work item the link points away from. - target_work_item (Ref or WorkItemSummary, required): Work item the link points at. - links (WorkItemLinkLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 duplicate-resource | idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/links' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "link_type": "blocks", "target_work_item_id": 1 }' ``` --- # GET /api/v2/work-items/{work_item}/subtasks List a work item's subtasks - MCP id: work-items.subtasks.index - Scope: api:read:project_management - Permission: project_management::view_backlog - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, no-store Returns one work item's checklist subtasks in their own checklist order, `order` ascending with the id as the tiebreaker. There is no organisation-wide collection of subtasks: a checklist row only means anything under the work item it belongs to, so this nested path is the only way to list them and `filter[work_item_id]` does not exist. Listing additionally requires `project_management::manage_work_items`. Defaults to the summary view, which omits the rich-text `description`; pass `view=detail` for it, or `include=work_item` to expand the work item each row points at. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[is_resolved] (boolean, optional): Narrow the checklist to the ticked-off or the outstanding subtasks. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `title`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - work_item_id (integer, required, server-assigned): The work item whose checklist this subtask belongs to. Taken from the path on create and not writable. - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (html or null, required, detail only): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, required): Whether the subtask is ticked off. - order (integer, required, server-assigned): Position in the work item's checklist. Assigned on create as the next position in that checklist, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - work_item (Ref or WorkItemSummary, required): The work item this subtask hangs off. - links (SubtaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-items/1/subtasks' \ --data-urlencode 'filter[is_resolved]=false' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-items/{work_item}/subtasks Add a subtask to a work item - MCP id: work-items.subtasks.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates one checklist subtask on the work item named in the path and returns it in the detail view, with a `Location` header pointing at the flat item path. The work item comes from the path, so `work_item_id` is not accepted in the body and a subtask can never be moved between work items. The new subtask is appended to the end of that work item's checklist; `order` is not accepted, and reordering is not available through the API. There is no batch create: post one subtask per request. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (string or null, optional): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, optional): Whether the subtask is ticked off. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - work_item_id (integer, required, server-assigned): The work item whose checklist this subtask belongs to. Taken from the path on create and not writable. - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (html or null, required, detail only): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, required): Whether the subtask is ticked off. - order (integer, required, server-assigned): Position in the work item's checklist. Assigned on create as the next position in that checklist, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - work_item (Ref or WorkItemSummary, required): The work item this subtask hangs off. - links (SubtaskLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/subtasks' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "title": "Wire up the SSO callback" }' ``` --- # POST /api/v2/work-items/{work_item}/uploads Start a file upload on a work item - MCP id: work-items.uploads.store - Scope: api:write:project_management - Permission: project_management::manage_work_items - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Step 1 of 3. Returns an upload ticket whose `upload_url` you PUT the raw bytes to, after which you POST the ticket's id as `upload_id` to `work-items/{work_item}/attachments` to turn those bytes into an attachment. The bytes never travel through this API: `/api/v2` accepts `application/json` and nothing else, so the upload target is a separate signed route. You declare only a file name and a size; the collection, the storage location, the disk and the ticket lifetime are all chosen by the server and no request field can influence them. The declared size is not a bound (the server counts what actually arrives and refuses anything over 10 MB), and an optional `checksum_sha256` extends the integrity guarantee back to your own disk. A file name carrying an executable extension anywhere in it, `shell.php.jpg` included, is refused here rather than after the upload. The ticket is yours alone, is single-use, expires in 30 minutes, and can be deleted with DELETE `uploads/{upload}` while unregistered. You may hold ten outstanding tickets at once. Deleted work items are `404`. ## Parameters - work_item (integer or string, path): The WorkItem's numeric id. Or its issue_key, matching `[A-Z][A-Z0-9]*-[0-9]+`. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer 1 to 10485760, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null ≤ 64, optional): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. Send this as `upload_id` when you register the bytes. - work_item_id (integer or null, required): The work item this ticket attaches to, or `null` when it targets a drive item. - drive_item_id (integer or null, required): The drive item this ticket attaches to, or `null` when it targets a work item. - collection (string ≤ 255, required): The media collection the file will land in. Always `attachments`: it is chosen by the route, and no request field can change it. - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null, required): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. - received_byte_size (integer or null, required): The number of bytes that actually arrived, measured by the server. Null until the upload lands. - received_checksum_sha256 (string or null, required): The SHA-256 the server computed over the bytes it received. Null until the upload lands. - mime_type (string or null ≤ 255, required): The media type detected from the received bytes. Detected, never declared: the API accepts no content type when creating the ticket and ignores the `Content-Type` of the upload itself. - status (string, required): Where the ticket is in its lifecycle. - upload_url (string or null, required): Where to PUT the file. An opaque absolute URL: send the raw file as the body of a PUT whose only header is your Authorization bearer token. No `Content-Type` is read and the body is stored exactly as sent, so a form envelope would become part of the file. Present only while status is `pending`. - expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): The deadline for the PUT. After it the ticket is dead and a new one must be created. - register_by (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): The deadline for the registration, set when the bytes land and measured from that moment, so a slow upload does not eat into the time you have to register. Null until the upload lands. - uploaded_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes arrived. Null until they do. - committed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes became a file. Null until they do. - media_id (integer or null, required): The id of the file this ticket became. Null until it is registered, and set exactly once. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the ticket was created. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (UploadLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused | state-conflict: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-items/1/uploads' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "file_name": "quarterly-report.pdf", "byte_size": 184320 }' ``` --- # GET /api/v2/work-types List work types - MCP id: work-types.index - Scope: api:read:work_types - Permission: work_types::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's work-item types (the categories work items are filed under) in plnnrly's display order, with the id as the tiebreaker. Types created through this API are created at position 0, so they tie with each other and sort by id. Available only while the Sprints feature is enabled. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created types are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (WorkTypeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/work-types' \ --data-urlencode 'q=bug' \ --data-urlencode 'sort=name' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/work-types Create a work type - MCP id: work-types.store - Scope: api:write:work_types - Permission: work_types::create - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a work-item type and returns it with a `Location` header. `order` is not accepted: the new type is created at position 0 and is reordered only by dragging it in plnnrly. `color` must be six-digit hex and `icon` must be an icon name such as `heroicon-o-tag`, because both are checked again on every read. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created types are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (WorkTypeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/work-types' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Spike", "color": "#6b7280", "icon": "heroicon-o-rectangle-stack" }' ``` --- # GET /api/v2/work-types/{work_type} Read a work type - MCP id: work-types.show - Scope: api:read:work_types - Permission: work_types::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one work-item type. The summary and detail views are identical on this resource: every field is small enough for a list row, and nothing is includable, so `include` is rejected. ## Parameters - work_type (integer, path): The WorkType's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created types are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (WorkTypeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/work-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/work-types/{work_type} Update a work type - MCP id: work-types.update - Scope: api:write:work_types - Permission: work_types::update - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a work-item type: absent keys are untouched, an empty body is rejected. `order` is not accepted. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - work_type (integer, path): The WorkType's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, optional): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, optional): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created types are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (WorkTypeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/work-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "color": "#ef4444" }' ``` --- # DELETE /api/v2/work-types/{work_type} Delete a work type - MCP id: work-types.destroy - Scope: api:destroy:work_types - Permission: work_types::delete - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a work-item type. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. A type that is still used by work items cannot be deleted: it answers `403` `forbidden-by-policy`, or `404` `resource-not-found` when your token does not also carry `work_types::read`, because a refusal naming a specific id must not confirm that id to a caller who cannot read the collection. Without that refusal, deleting the type would also delete every work item of that type together with its comments, subtasks, links and attachments. Retype the affected work items first, then delete. ## Parameters - work_type (integer, path): The WorkType's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: Work items still use this type. Move them to another type first: deleting the type would also delete them, with their comments, subtasks, links and attachments. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/work-types/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/workflow-statuses List workflow statuses - MCP id: workflow-statuses.index - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's workflow statuses (the board columns work items sit in) in workflow order, with the id as the tiebreaker. Statuses created through this API are created at position 0, so they tie with each other and sort by id. There is no nested path for one workflow's statuses: scope the collection with `filter[workflow_id]` instead, which is the URL a workflow's `links.statuses` points at. Available only while the Sprints feature is enabled. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. This collection's default sort is a display position that changes whenever someone drags the list into a new order in plnnrly, so a page number can skip or repeat a row if the list is reordered between two page requests. These collections rarely exceed one page; for a stable walk, send sort=id and follow links.next without page. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[is_resolution] (boolean, optional): Whether reaching the status resolves the work item. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[workflow_id] (integer, optional): Workflow the status belongs to. This is how you list one workflow's statuses: there is no nested path for them. Operators: = in. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, required): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. - order (integer, required, server-assigned): Position within the workflow, left to right on the board; set by dragging in plnnrly and not writable. API-created statuses are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this status belongs to. - links (WorkflowStatusLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/workflow-statuses' \ --data-urlencode 'filter[workflow_id]=4' \ --data-urlencode 'filter[is_resolution]=false' \ --data-urlencode 'include=workflow' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/workflow-statuses Create a workflow status - MCP id: workflow-statuses.store - Scope: api:write:workflows - Permission: workflows::create - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Adds a status to a workflow and returns it with a `Location` header, mirroring the status list in plnnrly's workflow form. `order` is not accepted: the new status is created at position 0 and is reordered only by dragging it in plnnrly. `color` must be six-digit hex, because it is re-validated on every read. A new status is unreachable until you also create the transitions leading into it: a work item can only be moved along an edge the workflow publishes. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - workflow_id (integer → Workflow.id, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, optional): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, required): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. - order (integer, required, server-assigned): Position within the workflow, left to right on the board; set by dragging in plnnrly and not writable. API-created statuses are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this status belongs to. - links (WorkflowStatusLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/workflow-statuses' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "workflow_id": 4, "name": "Done", "color": "#22c55e", "is_resolution": true }' ``` --- # GET /api/v2/workflow-statuses/{workflow_status} Read a workflow status - MCP id: workflow-statuses.show - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one workflow status. The summary and detail views are identical on this resource: every field is small enough for a list row. `workflow` renders as `{"id": N}` until named in `include`. ## Parameters - workflow_status (integer, path): The WorkflowStatus's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, required): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. - order (integer, required, server-assigned): Position within the workflow, left to right on the board; set by dragging in plnnrly and not writable. API-created statuses are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this status belongs to. - links (WorkflowStatusLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/workflow-statuses/1' \ --data-urlencode 'include=workflow' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/workflow-statuses/{workflow_status} Update a workflow status - MCP id: workflow-statuses.update - Scope: api:write:workflows - Permission: workflows::update - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a workflow status: absent keys are untouched, an empty body is rejected. `order` and `workflow_id` are not accepted: a status cannot be moved to another workflow, because its transitions and the work items sitting in it belong to the one it was created in. Flipping `is_resolution` re-decides what the backlog counts as done for every work item already in the status. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - workflow_status (integer, path): The WorkflowStatus's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, optional): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, optional): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, required): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. - order (integer, required, server-assigned): Position within the workflow, left to right on the board; set by dragging in plnnrly and not writable. API-created statuses are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this status belongs to. - links (WorkflowStatusLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/workflow-statuses/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "color": "#ef4444" }' ``` --- # DELETE /api/v2/workflow-statuses/{workflow_status} Delete a workflow status - MCP id: workflow-statuses.destroy - Scope: api:destroy:workflows - Permission: workflows::delete - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a workflow status. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. There is no in-use refusal, and the delete reaches further: one call also deletes every work item sitting in this status (with its comments, subtasks, links and attachments) and every transition leading into or out of it. plnnrly itself behaves the same way. Move the affected work items to another status first: list them with GET `/api/v2/work-items?filter[workflow_status_id]={id}`. ## Parameters - workflow_status (integer, path): The WorkflowStatus's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/workflow-statuses/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/workflow-transitions List workflow transitions - MCP id: workflow-transitions.index - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: each include +1, `q` +2. - Cache-Control: private, max-age=60 Returns the organisation's workflow transitions (the legal status moves work items may make), ordered by id. There is no nested path for one workflow's transitions: scope the collection with `filter[workflow_id]` instead. Available only while the Sprints feature is enabled. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[from_status_id] (integer, optional): Status the edge leads out of. Operators: = in. - filter[to_status_id] (integer, optional): Status the edge leads into. Operators: = in. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[workflow_id] (integer, optional): Workflow the edge belongs to. This is how you list one workflow's transitions: there is no nested path for them. Operators: = in. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer, required): Status the work item moves to. Always different from `from_status_id`. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this edge belongs to. - from_status (Ref or WorkflowStatusSummary, required): Status the edge leads out of. - to_status (Ref or WorkflowStatusSummary, required): Status the edge leads into. - links (WorkflowTransitionLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/workflow-transitions' \ --data-urlencode 'filter[workflow_id]=4' \ --data-urlencode 'include=from_status,to_status' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/workflow-transitions Create a workflow transition - MCP id: workflow-transitions.store - Scope: api:write:workflows - Permission: workflows::create - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Adds a legal status move to a workflow and returns it with a `Location` header, mirroring the "add transition" action in plnnrly. Both statuses must belong to the workflow named in `workflow_id`, and they must differ; a status from another workflow is refused as if it did not exist. The three ids are validated together, so an unusable `workflow_id` also fails both status fields. Duplicate edges are legal, exactly as they are in plnnrly. The work-item transition endpoint accepts the new move as soon as this call returns. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - workflow_id (integer → Workflow.id, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer → WorkflowStatus.id, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer → WorkflowStatus.id, required): Status the work item moves to. Always different from `from_status_id`. Must differ from from_status_id. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer, required): Status the work item moves to. Always different from `from_status_id`. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this edge belongs to. - from_status (Ref or WorkflowStatusSummary, required): Status the edge leads out of. - to_status (Ref or WorkflowStatusSummary, required): Status the edge leads into. - links (WorkflowTransitionLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/workflow-transitions' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "workflow_id": 4, "from_status_id": 11, "to_status_id": 12, "name": "Start progress" }' ``` --- # GET /api/v2/workflow-transitions/{workflow_transition} Read a workflow transition - MCP id: workflow-transitions.show - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, max-age=60 Returns one workflow transition. The summary and detail views are identical on this resource: every field is small enough for a list row. `workflow`, `from_status` and `to_status` render as `{"id": N}` references until named in `include`. ## Parameters - workflow_transition (integer, path): The WorkflowTransition's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer, required): Status the work item moves to. Always different from `from_status_id`. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this edge belongs to. - from_status (Ref or WorkflowStatusSummary, required): Status the edge leads out of. - to_status (Ref or WorkflowStatusSummary, required): Status the edge leads into. - links (WorkflowTransitionLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/workflow-transitions/1' \ --data-urlencode 'include=from_status,to_status' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/workflow-transitions/{workflow_transition} Update a workflow transition - MCP id: workflow-transitions.update - Scope: api:write:workflows - Permission: workflows::update - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a workflow transition: absent keys are untouched, an empty body is rejected. `workflow_id` is not accepted: an edge cannot be moved to another workflow, because its two statuses belong to the one it was created in. The two status fields must be sent together or not at all, and they must differ; both are checked against the transition's own workflow. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. ## Parameters - workflow_transition (integer, path): The WorkflowTransition's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Transition label as shown on the status button, e.g. `Start progress`. - from_status_id (integer → WorkflowStatus.id, optional): Status a work item must currently be in for this edge to apply. Required when to_status_id is present. - to_status_id (integer → WorkflowStatus.id, optional): Status the work item moves to. Always different from `from_status_id`. Required when from_status_id is present. Must differ from from_status_id. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer, required): Status the work item moves to. Always different from `from_status_id`. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this edge belongs to. - from_status (Ref or WorkflowStatusSummary, required): Status the edge leads out of. - to_status (Ref or WorkflowStatusSummary, required): Status the edge leads into. - links (WorkflowTransitionLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/workflow-transitions/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Begin work" }' ``` --- # DELETE /api/v2/workflow-transitions/{workflow_transition} Delete a workflow transition - MCP id: workflow-transitions.destroy - Scope: api:destroy:workflows - Permission: workflows::delete - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a workflow transition, mirroring the delete action in plnnrly. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. Work items already sitting on either status are unaffected; they simply lose a legal next move, and the work-item transition endpoint starts refusing it as soon as this call returns. ## Parameters - workflow_transition (integer, path): The WorkflowTransition's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/workflow-transitions/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/workflows List workflows - MCP id: workflows.index - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `q` +2. - Cache-Control: private, no-store Returns the organisation's workflows (the named status sets work items move through), sorted by name A→Z by default, with the id as the tiebreaker. Each row carries `statuses_count` and a `links.statuses` URL; the statuses themselves are never inlined. Available only while the Sprints feature is enabled. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - page (integer 1 to 1000, optional): Opt in to page-number paging: 1-based, at most 1000. Omit it, the default, to page by opaque cursor, which is what links.next hands you. Cannot be combined with cursor. Published only on collections that count their rows on every request. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - q (string, optional): Free-text search over `name`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created workflows are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - statuses_count (integer ≥ 0, required): Number of statuses in this workflow. - links (WorkflowLinks, required) - self (url, required): The canonical URI of this record. - statuses (url, required): Statuses in this workflow, in workflow order. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/workflows' \ --data-urlencode 'q=standard' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/workflows Create a workflow - MCP id: workflows.store - Scope: api:write:workflows - Permission: workflows::create - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Creates a workflow and returns it with a `Location` header. `name` is the only accepted field: `order` is not writable, so the new workflow is created at position 0 and is reordered only by dragging it in plnnrly. The workflow is created empty (with zero statuses and zero transitions), because statuses and transitions are separate collections: `POST /api/v2/workflow-statuses` with `workflow_id` for each status, then POST `/api/v2/workflow-transitions` to connect them. That sequence is not atomic, so a client that fails partway leaves a workflow with an incomplete status set, and a project pointed at it cannot move work items along the missing transitions. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, optional): A caller-chosen key that makes this POST safe to retry. Replaying it returns the original response with Idempotency-Replayed: true. ## Request body - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created workflows are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - statuses_count (integer ≥ 0, required): Number of statuses in this workflow. - links (WorkflowLinks, required) - self (url, required): The canonical URI of this record. - statuses (url, required): Statuses in this workflow, in workflow order. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/workflows' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ "name": "Bug triage" }' ``` --- # GET /api/v2/workflows/{workflow} Read a workflow - MCP id: workflows.show - Scope: api:read:workflows - Permission: workflows::read - Needs the sprints feature on your organisation - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one workflow. The summary and detail views are identical on this resource: every field is small enough for a list row, and nothing is includable, so `include` is rejected. Read the workflow's statuses through `links.statuses`, and its transitions through `GET /api/v2/workflow-transitions?filter[workflow_id]={id}`. ## Parameters - workflow (integer, path): The Workflow's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created workflows are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - statuses_count (integer ≥ 0, required): Number of statuses in this workflow. - links (WorkflowLinks, required) - self (url, required): The canonical URI of this record. - statuses (url, required): Statuses in this workflow, in workflow order. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X GET \ 'https:///api/v2/workflows/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/workflows/{workflow} Update a workflow - MCP id: workflows.update - Scope: api:write:workflows - Permission: workflows::update - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Renames a workflow. `name` is the only writable key, so an empty body is rejected and any other key is rejected. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision, so two writes inside the same second are indistinguishable. Renaming a workflow does not touch its statuses or transitions. ## Parameters - workflow (integer, path): The Workflow's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - name (string ≤ 255, optional): Workflow name as shown in the UI, e.g. `Standard`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created workflows are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - statuses_count (integer ≥ 0, required): Number of statuses in this workflow. - links (WorkflowLinks, required) - self (url, required): The canonical URI of this record. - statuses (url, required): Statuses in this workflow, in workflow order. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/workflows/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "name": "Standard (2026)" }' ``` --- # DELETE /api/v2/workflows/{workflow} Delete a workflow - MCP id: workflows.destroy - Scope: api:destroy:workflows - Permission: workflows::delete - Needs the sprints feature on your organisation - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a workflow. This resource is hard-delete: there is no restore action, no `force` parameter and no `trashed` parameter. This deletes more and is not refused. Every status and every transition in the workflow is deleted with it, and every work item that sat in one of those statuses is deleted as well, taking its subtasks, its links to other work items, its label assignments and its document attachments with it, and detaching the time logged against it. Every project using this workflow is silently detached from it. There is no in-use check and no refusal: a workflow that is driving live projects deletes just as readily as an unused one, which matches plnnrly's equally unguarded delete action. Repoint the affected projects at another workflow first. ## Parameters - workflow (integer, path): The Workflow's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/workflows/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # GET /api/v2/worklogs List worklogs - MCP id: worklogs.index - Scope: api:read:times - Permission: hours::read - Rate cost: 1 unit, read bucket. `view=detail` makes it 3. Adds: `with_total=1` +2, each include +1, `q` +2. - Cache-Control: private, no-store Returns booked hours, newest first by `date`. A caller sees only their own entries unless they hold `times::update_others` or `times::delete_others`, and that narrowing is applied before any client filter, before pagination and before the count, so `filter[user_id]` widens nothing on its own. `meta.page.total` is `null` unless you ask for it with `with_total=1`, which adds 2 read units, because worklogs are usually an organisation's largest collection. `q` matches anywhere in `description`. `project` and `work_item` are on every row already, so naming either in `?include=` is a `400`; `budget`, `task` and `user` are the includable relations, and each additionally requires the read permission and token scope of its own prefix. There is no `duration` string and no `is_in_review` flag: format `minutes` yourself, and read review state as `(exceeded_budget || billable_change_reason) && reviewed_by == null && invoice_id == null`. ## Parameters - view (string, optional): Which field set to render. - per_page (integer 1 to 100, optional): Rows per page. At most 25 when `include` names a to-many relation; a larger value is 400 invalid-query-parameter. - cursor (string, optional): Opaque keyset cursor from a previous page's links.next. Never construct one by hand. - with_total (boolean, optional): Whether meta.page.total is counted for this request. - filter[budget_id] (integer, optional): Budget the hours are invoiced against. Operators: = in null. - filter[created_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Creation timestamp. Pair with `sort=id` for a stable full export. Operators: = gte lt. - filter[date] (date (YYYY-MM-DD), optional): The calendar day the work happened. Combine gte and lte for a range. Operators: = gte lte. - filter[exceeded_budget] (boolean, optional): Whether the entry was booked knowing it exceeds its budget. - filter[invoice_id] (integer, optional): The invoice these hours were billed on. Use the `null` operator, `filter[invoice_id][null]=true`, to find uninvoiced hours. Operators: = in null. - filter[is_not_billable] (boolean, optional): Whether the hours are excluded from invoicing. - filter[is_overtime] (boolean, optional): Whether the hours count as overtime. - filter[project_id] (integer, optional): Project the hours are booked against. Operators: = in null. - filter[reviewed_by] (integer, optional): The reviewer who settled the entry. Use `filter[reviewed_by][null]=true` to find entries nobody has reviewed. Operators: = in null. - filter[sales_invoice_row_id] (integer, optional): The sales-invoice line these hours were billed on. Operators: = in null. - filter[task_id] (integer, optional): Task the hours are booked on. Operators: = in null. - filter[updated_at] (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): Last-write timestamp. Use for incremental sync. Operators: = gte lt. - filter[user_id] (integer, optional): Whose hours to return. Accepts the literal `me`, and defaults to the authenticated user. Naming another employee requires the `times::update_others` permission. Operators: = in null. - filter[work_item_id] (integer, optional): Work item the hours are attached to. Operators: = in null. - q (string, optional): Free-text search over `description`. At most one substring match per request: `q` together with a `contains` filter is 400 invalid-query-parameter. - sort (string, optional): Up to three keys, comma-separated, each optionally prefixed with `-` for descending. - include (string, optional): Comma-separated names to inline, at most five. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted. - project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested. - budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted. - budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix. - task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection. - work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded. - user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST. - user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it. - invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`. - sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/worklogs' \ --data-urlencode 'filter[user_id]=me' \ --data-urlencode 'filter[date][gte]=2026-09-07' \ --data-urlencode 'filter[date][lte]=2026-09-13' \ --data-urlencode 'include=task' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # POST /api/v2/worklogs Log time - MCP id: worklogs.store - Scope: api:write:times - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Books minutes against a project on one calendar day and returns the entry with a `Location` header. `user_id` is not accepted: the hours belong to the authenticated user unless `on_behalf_of_user_id` names someone else, and logging time for another user additionally requires `times::update_others`, enforced by that field. Every check inside the write (whether that person may log time on the project, owns the task and may log time on the work item) is evaluated against the person the hours are for, not against you, so `is_overtime` is dropped when they lack `times::write_overtime` even if you hold it. One exception, which plnnrly's own form shares: the check that project, task and budget fit together weighs the task against you, so filing hours on somebody else's task needs `times::write_on_others_tasks` from the caller as well. A task or work item that does not belong to the submitted project is a `422` on its own field; a task or work item you may read but may not book against is a `403`. `budget_id` is derived from the task and never accepted. `is_not_billable` is inferred from the task when omitted; sending a value that differs from the inference requires `billable_change_reason`, answering 422 on `/billable_change_reason` without it. Sending a `reason` marks the entry as exceeding its budget whether or not `exceeded_budget` is sent alongside. Either an exceeded budget or a billability override puts the entry in a reviewer's queue, and while it sits there every write to it by anyone but its author or a reviewer answers `409`. `Idempotency-Key` is required here: a retried time entry is real, invoiced damage. The API is stricter than plnnrly here: a task is accepted only if you can also see it on the tasks collection, so a caller holding `times::write_on_others_tasks` but not `kanban::manage_for_others` gets a `422` `exists` for a task plnnrly would accept. ## Parameters - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - Idempotency-Key (header, required): A caller-chosen key that makes this POST safe to retry. Required on this operation: omitting it is 400 idempotency-key-required. ## Request body - project_id (integer → Project.id, required): Project the hours are booked against; `null` once that project is deleted. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer 1 to 1440, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - task_id (integer or null → Task.id, optional): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - work_item_id (integer or null → WorkItem.id, optional): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - is_overtime (boolean, optional): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - is_not_billable (boolean, optional): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - billable_change_reason (string or null ≤ 65535, optional): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - exceeded_budget (boolean, optional): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null ≤ 65535, optional): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - on_behalf_of_user_id (integer → User.id, optional): Write-only, on create only: the employee to file the hours for. Omit it to file them for yourself. Naming anyone else requires the `times::update_others` permission, and every check inside the write (whether that person may log on the project and on that task, and whether overtime may be recorded) is then evaluated against them, not against you. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 201 - id (integer, required): Unique identifier. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted. - project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested. - budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted. - budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix. - task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection. - work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded. - user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST. - user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it. - invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`. - sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 idempotency-key-in-flight | idempotency-key-reused: Conflict. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X POST \ 'https:///api/v2/worklogs' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: example-key-0001' \ -d '{ "project_id": 12, "work_item_id": 41, "task_id": 88, "date": "2026-09-10", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding" }' ``` --- # GET /api/v2/worklogs/{worklog} Read a worklog - MCP id: worklogs.show - Scope: api:read:times - Permission: hours::read - Rate cost: 1 unit, read bucket. - Cache-Control: private, no-store Returns one worklog. `reason` and `billable_change_reason` are detail-view fields, so `?view=detail` is what fetches the justifications a list row omits. An entry belonging to another employee is `404` unless the caller holds `times::update_others` or `times::delete_others`: the row is invisible, so its existence stays invisible. `project` and `work_item` are always expanded; `budget`, `task` and `user` expand under `?include=`, and each additionally requires the read permission and token scope of its own prefix. ## Parameters - worklog (integer, path): The Time's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-None-Match (header, optional): The ETag a read is conditional on. A match is answered 304 with no body. ## Response 200 - id (integer, required): Unique identifier. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted. - project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested. - budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted. - budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix. - task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection. - work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded. - user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST. - user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it. - invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`. - sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 304 : Not modified. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -G \ 'https:///api/v2/worklogs/1' \ --data-urlencode 'view=detail' \ --data-urlencode 'include=budget,task,user' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # PATCH /api/v2/worklogs/{worklog} Correct a worklog - MCP id: worklogs.update - Scope: api:write:times - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for. - Cache-Control: private, no-store Partially updates a worklog: absent keys are untouched, an empty body is rejected. Changing another user's entry additionally requires `times::update_others`; without it, other people's entries are invisible to you. An entry awaiting review answers `409` with `conflict.reason` `locked_by_review` to anyone but its author or a reviewer, and an invoiced entry answers `409` `locked_by_invoice`: an invoiced entry can never be changed again. `exceeded_budget` is read-only here: it is what puts an entry in the reviewer's queue, and clearing it would withdraw the entry from that queue silently. `reason` stays writable until a reviewer has settled the entry; rewriting it afterwards answers `409` `reason_reviewed`. Sending `task_id` re-infers `is_not_billable` from the task, and sending `is_not_billable` with a value that differs from the inferred or current one requires `billable_change_reason` (`422` on `/billable_change_reason` without it) and puts the entry back in the reviewer's queue, reopening one that was already settled. A new `project_id` must be one the entry's owner holds a task on, and a new `task_id` must be the owner's own task in the entry's project, so a manager holding `times::update_others` may move a colleague's hours onto that colleague's deadline in another project. Only the caller's `times::write_on_others_tasks` widens either rule, never the owner's; a refusal answers `403` `forbidden-by-policy`, and a task outside the project a `422` on `/task_id`. Moving the entry with `project_id` detaches a task or work item that cannot follow it and nulls `budget_id` with the task. Reassigning a worklog to another employee is not possible: that is a DELETE plus a POST. `work_item_id` is checked for existence, visibility and the same project, and moving the entry onto a different work item additionally requires that the entry's owner may log time on it, the same rule plnnrly and the create operation apply; otherwise it answers `403` `forbidden-by-policy`. Send `If-Match` with the `ETag` from your last read to detect a concurrent edit, which answers `412`. Treat it as best-effort only: this resource carries no concurrency token, and `updated_at` is second-precision. ## Parameters - worklog (integer, path): The Time's numeric id. - view (string, optional): Which field set to render. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Request body - date (date (YYYY-MM-DD), optional): The calendar day the work happened. - minutes (integer 1 to 1440, optional): Duration in minutes, between 1 and 1440. - description (string ≤ 255, optional): What the employee worked on. - project_id (integer → Project.id, optional): Project the hours are booked against; `null` once that project is deleted. - task_id (integer or null → Task.id, optional): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - work_item_id (integer or null → WorkItem.id, optional): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - is_overtime (boolean, optional): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - is_not_billable (boolean, optional): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - billable_change_reason (string or null ≤ 65535, optional): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reason (string or null ≤ 65535, optional): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. Unknown keys are refused with `422` validation-failed, naming the key. ## Response 200 - id (integer, required): Unique identifier. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted. - project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested. - budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted. - budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix. - task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection. - work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded. - user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST. - user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it. - invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`. - sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimeLinks, required) - self (url, required): The canonical URI of this record. ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - `forbidden-by-policy`: You point the entry at something its owner may not log time on: a `project_id` where the owner holds no task, a `task_id` that is not one of the owner's own tasks, or a `work_item_id` the owner may not log time on. Holding `times::write_on_others_tasks` lifts the first two rules, never the third. You also get this answer on a colleague's entry when you hold `times::delete_others` but not `times::update_others`: either permission lets you see it, only `times::update_others` lets you change it. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 415 unsupported-media-type: Unsupported media type. - 422 unprocessable-relationship | validation-failed: Unprocessable content. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X PATCH \ 'https:///api/v2/worklogs/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'If-Match: *' \ -d '{ "minutes": 90, "description": "Reviewed the invoice export (rounding fix split out)" }' ``` --- # DELETE /api/v2/worklogs/{worklog} Delete a worklog - MCP id: worklogs.destroy - Scope: api:destroy:times - Permission: none, the record's own rules still apply - Rate cost: 1 unit, write bucket. - Cache-Control: private, no-store Permanently deletes a worklog, like the delete action in plnnrly. This resource is hard-delete: there is no restore action, no `force` parameter, no `trashed` parameter and no deletion signal for a sync client, so a client tracking hours learns of a deletion only by the row's absence. Deleting another user's entry additionally requires `times::delete_others`; note it is `delete_others` here and `update_others` on PATCH. Either permission makes colleagues' entries visible, so a caller holding only `update_others` sees a colleague's entry and gets `403` `forbidden-by-policy` here. An entry awaiting review answers `409` `locked_by_review` to anyone but its author or a reviewer, and an invoiced entry answers `409` `locked_by_invoice`: invoiced hours can never be deleted, because the worklog would then diverge from what the customer was billed. ## Parameters - worklog (integer, path): The Time's numeric id. - X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected. - If-Match (header, optional): The ETag a write is conditional on. A stale validator is 412 precondition-failed. ## Response 204 ## Errors - 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden. - 409 state-conflict: Conflict. - 412 precondition-failed: Precondition failed. - 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request. - 401 token-expired | unauthenticated: Unauthenticated. - 404 endpoint-not-found | resource-not-found: Not found. - 406 not-acceptable: Not acceptable. - 429 rate-limit-exceeded: Too many requests. - 500 internal-error: Server error. - 503 tenant-unavailable: Service unavailable. ## Example ```bash curl -X DELETE \ 'https:///api/v2/worklogs/1' \ -H 'Authorization: Bearer ' \ -H 'Accept: application/json' ``` --- # Schemas --- # Problems Every error document /api/v2 returns. All of them are `application/problem+json` (RFC 9457). ## Every problem carries - type (url, required): The problem type. This is the only member a client switches on. - title (string, required): Short, stable per type, English, never interpolated. Never parse it. - status (integer, required): The HTTP status, repeated in the body. - detail (string, required): Human-readable explanation. May quote values from your request; never shows internal error output. - instance (string, required): The request path, with no query string. - request_id (string, required): The id also in the X-Request-Id header. Quote it when you report a problem to plnnrly support. ## Which problem a status can be - 400: idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter. Bad request. - 401: token-expired | unauthenticated. Unauthenticated. - 403: forbidden-by-policy | insufficient-permission | insufficient-token-scope. Forbidden. - 404: endpoint-not-found | resource-not-found. Not found. - 405: method-not-allowed. Method not allowed. - 406: not-acceptable. Not acceptable. - 409Idempotency: idempotency-key-in-flight | idempotency-key-reused. Conflict. - 412: precondition-failed. Precondition failed. - 415: unsupported-media-type. Unsupported media type. - 422: unprocessable-relationship | validation-failed. Unprocessable content. - 429: rate-limit-exceeded. Too many requests. - 500: internal-error. Server error. - 503: tenant-unavailable. Service unavailable. ## The documents ### DuplicateResourceProblem - Status: 409 - Type: https://developers.plnnrly.com/api/v2/problems/duplicate-resource - Title: Duplicate resource Extra members: - conflict (object, optional): Machine-readable context for the uniqueness failure. - field (string, required): The field whose value is already taken. - value (string, required): The rejected value. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/duplicate-resource", "title": "Duplicate resource", "status": 409, "detail": "Another project already uses the code \"ACM\".", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "conflict": { "field": "code", "value": "ACM" } } ``` ### EndpointNotFoundProblem - Status: 404 - Type: https://developers.plnnrly.com/api/v2/problems/endpoint-not-found - Title: Endpoint not found ```json { "type": "https://developers.plnnrly.com/api/v2/problems/endpoint-not-found", "title": "Endpoint not found", "status": 404, "detail": "No API endpoint matches this request.", "instance": "/api/v2/nope", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### EndpointRetiredProblem - Status: 410 - Type: https://developers.plnnrly.com/api/v2/problems/endpoint-retired - Title: Endpoint retired Extra members: - sunset (date (YYYY-MM-DD), optional): The date this endpoint stopped working. - replaced_by (string or null, optional): The route name that replaces it. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/endpoint-retired", "title": "Endpoint retired", "status": 410, "detail": "This endpoint has been retired and no longer serves requests.", "instance": "/api/v2/projects/4711/statistics", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "sunset": "2027-09-01", "replaced_by": "api.v2.projects.show" } ``` ### ForbiddenByPolicyProblem - Status: 403 - Type: https://developers.plnnrly.com/api/v2/problems/forbidden-by-policy - Title: Forbidden by policy ```json { "type": "https://developers.plnnrly.com/api/v2/problems/forbidden-by-policy", "title": "Forbidden by policy", "status": 403, "detail": "You may not perform this operation on this record.", "instance": "/api/v2/projects/4711", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### IdempotencyKeyInFlightProblem - Status: 409 - Type: https://developers.plnnrly.com/api/v2/problems/idempotency-key-in-flight - Title: Idempotency key in flight ```json { "type": "https://developers.plnnrly.com/api/v2/problems/idempotency-key-in-flight", "title": "Idempotency key in flight", "status": 409, "detail": "The original request for this Idempotency-Key is still being processed. Retry shortly.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### IdempotencyKeyRequiredProblem - Status: 400 - Type: https://developers.plnnrly.com/api/v2/problems/idempotency-key-required - Title: Idempotency key required ```json { "type": "https://developers.plnnrly.com/api/v2/problems/idempotency-key-required", "title": "Idempotency key required", "status": 400, "detail": "This endpoint requires an Idempotency-Key header.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### IdempotencyKeyReusedProblem - Status: 409 - Type: https://developers.plnnrly.com/api/v2/problems/idempotency-key-reused - Title: Idempotency key reused ```json { "type": "https://developers.plnnrly.com/api/v2/problems/idempotency-key-reused", "title": "Idempotency key reused", "status": 409, "detail": "This Idempotency-Key was already used with a different request body.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### InsufficientPermissionProblem - Status: 403 - Type: https://developers.plnnrly.com/api/v2/problems/insufficient-permission - Title: Insufficient permission Extra members: - required_permission (string, optional): The permission the user is missing, e.g. `projects::read`. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/insufficient-permission", "title": "Insufficient permission", "status": 403, "detail": "Your role does not carry the permission this endpoint requires.", "instance": "/api/v2/projects/4711", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "required_permission": "projects::read" } ``` ### InsufficientTokenScopeProblem - Status: 403 - Type: https://developers.plnnrly.com/api/v2/problems/insufficient-token-scope - Title: Insufficient token scope Extra members: - required_ability (array of string, optional): The token abilities, any one of which would have permitted this request, e.g. `["api:read:project_drive", "api:read:customer_drive"]`. A `*`-prefix route lists every ability of the required class. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/insufficient-token-scope", "title": "Insufficient token scope", "status": 403, "detail": "This token does not carry an ability that covers this endpoint.", "instance": "/api/v2/drive-documents/91", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "required_ability": [ "api:read:project_drive", "api:read:customer_drive" ] } ``` ### InternalErrorProblem - Status: 500 - Type: https://developers.plnnrly.com/api/v2/problems/internal-error - Title: Internal error ```json { "type": "https://developers.plnnrly.com/api/v2/problems/internal-error", "title": "Internal error", "status": 500, "detail": "An unexpected error occurred. Quote the request_id when contacting support.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### InvalidCursorProblem - Status: 400 - Type: https://developers.plnnrly.com/api/v2/problems/invalid-cursor - Title: Invalid cursor ```json { "type": "https://developers.plnnrly.com/api/v2/problems/invalid-cursor", "title": "Invalid cursor", "status": 400, "detail": "The cursor is not valid for this query. Restart the scan without a cursor.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### InvalidQueryParameterProblem - Status: 400 - Type: https://developers.plnnrly.com/api/v2/problems/invalid-query-parameter - Title: Invalid query parameter Extra members: - errors (array of object, optional): One entry per fault. Each carries exactly one of `pointer` or `parameter`. - code (string, required): Machine-readable fault code. Switch on this, never on detail. - detail (string, required): Human-readable explanation; enumerates the allowed set for an allow-list failure. - pointer (string or null, optional): RFC 6901 pointer into the request body, e.g. `/minutes`. - parameter (string or null, optional): The query-parameter name, brackets included, e.g. `filter[start_date][gte]`. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/invalid-query-parameter", "title": "Invalid query parameter", "status": 400, "detail": "1 query parameter was not recognised or not permitted on this resource.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "errors": [ { "parameter": "limit", "code": "unknown_parameter", "detail": "Unknown query parameter \"limit\". Allowed: filter[…], sort, include, q, trashed, per_page, cursor, page, with_total, view, meta, force, url_ttl_minutes." } ] } ``` ### MalformedRequestProblem - Status: 400 - Type: https://developers.plnnrly.com/api/v2/problems/malformed-request - Title: Malformed request ```json { "type": "https://developers.plnnrly.com/api/v2/problems/malformed-request", "title": "Malformed request", "status": 400, "detail": "The request could not be read.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### MethodNotAllowedProblem - Status: 405 - Type: https://developers.plnnrly.com/api/v2/problems/method-not-allowed - Title: Method not allowed ```json { "type": "https://developers.plnnrly.com/api/v2/problems/method-not-allowed", "title": "Method not allowed", "status": 405, "detail": "PUT is not supported. This API patches: use PATCH with only the fields you are changing.", "instance": "/api/v2/projects/4711", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### NotAcceptableProblem - Status: 406 - Type: https://developers.plnnrly.com/api/v2/problems/not-acceptable - Title: Not acceptable ```json { "type": "https://developers.plnnrly.com/api/v2/problems/not-acceptable", "title": "Not acceptable", "status": 406, "detail": "This API only produces application/json. Send an Accept header that allows it.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### PreconditionFailedProblem - Status: 412 - Type: https://developers.plnnrly.com/api/v2/problems/precondition-failed - Title: Precondition failed Extra members: - current_version (integer or null, optional): The record's current version, where it has one. Null otherwise. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/precondition-failed", "title": "Precondition failed", "status": 412, "detail": "The record changed since the version named in If-Match. Re-read it and retry.", "instance": "/api/v2/work-items/812", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "current_version": 12 } ``` ### RateLimitExceededProblem - Status: 429 - Type: https://developers.plnnrly.com/api/v2/problems/rate-limit-exceeded - Title: Rate limit exceeded Extra members: - rate_limit (object, optional): Which ceiling was exhausted, and for how long. - limit (integer, required): Units permitted per minute in the exhausted bucket. - retry_after (integer, required): Seconds until the bucket refills. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/rate-limit-exceeded", "title": "Rate limit exceeded", "status": 429, "detail": "The rate limit for this caller has been exhausted. Retry after the interval in the Retry-After header.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "rate_limit": { "limit": 600, "retry_after": 42 } } ``` ### ResourceNotFoundProblem - Status: 404 - Type: https://developers.plnnrly.com/api/v2/problems/resource-not-found - Title: Resource not found ```json { "type": "https://developers.plnnrly.com/api/v2/problems/resource-not-found", "title": "Resource not found", "status": 404, "detail": "No Project was found with id 4711.", "instance": "/api/v2/projects/4711", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### StateConflictProblem - Status: 409 - Type: https://developers.plnnrly.com/api/v2/problems/state-conflict - Title: State conflict Extra members: - conflict (object, optional): Machine-readable context. `reason` is always present; the other keys depend on it. - reason (string, required): Why the record's current state forbids the operation. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/state-conflict", "title": "State conflict", "status": 409, "detail": "The target status is not reachable from this work item's current status.", "instance": "/api/v2/work-items/812/actions/transition", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "conflict": { "reason": "illegal_transition", "from_workflow_status_id": 3, "to_workflow_status_id": 9 } } ``` ### TenantUnavailableProblem - Status: 503 - Type: https://developers.plnnrly.com/api/v2/problems/tenant-unavailable - Title: Tenant unavailable ```json { "type": "https://developers.plnnrly.com/api/v2/problems/tenant-unavailable", "title": "Tenant unavailable", "status": 503, "detail": "This organisation is provisioning, suspended or archived, or the service is down for maintenance.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### TokenExpiredProblem - Status: 401 - Type: https://developers.plnnrly.com/api/v2/problems/token-expired - Title: Token expired Extra members: - expired_at (timestamp (YYYY-MM-DDThh:mm:ssZ), optional): When the token expired. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/token-expired", "title": "Token expired", "status": 401, "detail": "This token has expired. Create a new one under API access in your user menu.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "expired_at": "2026-08-01T09:00:00Z" } ``` ### UnauthenticatedProblem - Status: 401 - Type: https://developers.plnnrly.com/api/v2/problems/unauthenticated - Title: Unauthenticated ```json { "type": "https://developers.plnnrly.com/api/v2/problems/unauthenticated", "title": "Unauthenticated", "status": 401, "detail": "No valid bearer token was supplied.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### UnprocessableRelationshipProblem - Status: 422 - Type: https://developers.plnnrly.com/api/v2/problems/unprocessable-relationship - Title: Unprocessable relationship Extra members: - errors (array of object, optional): One entry per fault. Each carries exactly one of `pointer` or `parameter`. - code (string, required): Machine-readable fault code. Switch on this, never on detail. - detail (string, required): Human-readable explanation; enumerates the allowed set for an allow-list failure. - pointer (string or null, optional): RFC 6901 pointer into the request body, e.g. `/minutes`. - parameter (string or null, optional): The query-parameter name, brackets included, e.g. `filter[start_date][gte]`. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/unprocessable-relationship", "title": "Unprocessable relationship", "status": 422, "detail": "1 referenced record cannot be used here.", "instance": "/api/v2/work-items", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "errors": [ { "pointer": "/sprint_id", "code": "exists", "detail": "Sprint 55 belongs to another project and cannot be used here." } ] } ``` ### UnsupportedMediaTypeProblem - Status: 415 - Type: https://developers.plnnrly.com/api/v2/problems/unsupported-media-type - Title: Unsupported media type ```json { "type": "https://developers.plnnrly.com/api/v2/problems/unsupported-media-type", "title": "Unsupported media type", "status": 415, "detail": "This API only accepts application/json request bodies.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S" } ``` ### UnsupportedParameterProblem - Status: 400 - Type: https://developers.plnnrly.com/api/v2/problems/unsupported-parameter - Title: Unsupported parameter Extra members: - errors (array of object, optional): One entry per fault. Each carries exactly one of `pointer` or `parameter`. - code (string, required): Machine-readable fault code. Switch on this, never on detail. - detail (string, required): Human-readable explanation; enumerates the allowed set for an allow-list failure. - pointer (string or null, optional): RFC 6901 pointer into the request body, e.g. `/minutes`. - parameter (string or null, optional): The query-parameter name, brackets included, e.g. `filter[start_date][gte]`. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/unsupported-parameter", "title": "Unsupported parameter", "status": 400, "detail": "1 query parameter is not supported on this endpoint.", "instance": "/api/v2/projects", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "errors": [ { "parameter": "fields", "code": "unsupported_parameter", "detail": "Sparse fieldsets are not supported by this API. Use view=summary or view=detail instead." } ] } ``` ### ValidationFailedProblem - Status: 422 - Type: https://developers.plnnrly.com/api/v2/problems/validation-failed - Title: Validation failed Extra members: - errors (array of object, optional): One entry per fault. Each carries exactly one of `pointer` or `parameter`. - code (string, required): Machine-readable fault code. Switch on this, never on detail. - detail (string, required): Human-readable explanation; enumerates the allowed set for an allow-list failure. - pointer (string or null, optional): RFC 6901 pointer into the request body, e.g. `/minutes`. - parameter (string or null, optional): The query-parameter name, brackets included, e.g. `filter[start_date][gte]`. ```json { "type": "https://developers.plnnrly.com/api/v2/problems/validation-failed", "title": "Validation failed", "status": 422, "detail": "3 fields failed validation.", "instance": "/api/v2/worklogs", "request_id": "01K4B7Q9YV3PZ2N8T1E6M0XR4S", "errors": [ { "pointer": "/date", "code": "date_format", "detail": "The date does not match the format Y-m-d." }, { "pointer": "/minutes", "code": "min", "detail": "The minutes must be at least 1." }, { "pointer": "/description", "code": "required", "detail": "The description field is required." } ] } ``` --- # Pagination The collection envelope, the page and link shapes, and the objects every collection shares. ## The collection envelope As `GET /work-items` declares it, the widest form in the document. - data (array of WorkItemList, required) - meta (WorkItemsIndexMeta, required) - page (PageOptionalTotal, required) - per_page (integer, required): The page size that was applied. - next_cursor (string or null, required): Cursor for the next page, or null on the last one. - prev_cursor (string or null, required): Cursor for the previous page, or null on the first one. - total (integer or null, required): The total number of matching rows; null unless with_total=1 was sent. - warnings (array of object, optional): Non-fatal notes about this request. Switch on `code`, never on `detail`. - code (string, required): Machine-readable warning code. Switch on this, never on detail. - detail (string, required): Human-readable explanation. - pointer (string or null, optional): RFC 6901 pointer into the request body, where the warning is about a body field. - parameter (string or null, optional): The query-parameter name, brackets included, where the warning is about a parameter. - sunset (date (YYYY-MM-DD) or null, optional): Date the deprecated thing stops working. - replacement (string or null, optional): What to use instead. - links (PageLinks, required) - self (url, required): This page. - next (url or null, required): The next page, or null on the last one. - prev (url or null, required): The previous page, or null on the first one. ## Page - per_page (integer, required): The page size that was applied. - next_cursor (string or null, required): Cursor for the next page, or null on the last one. Always null in page mode. - prev_cursor (string or null, required): Cursor for the previous page, or null on the first one. Always null in page mode. - total (integer, required): The total number of matching rows. - current_page (integer or null, required): The page number this response answers, echoed from the request even when it exceeds last_page. Null when the request did not send page. - last_page (integer or null, required): The highest page number that holds rows: ceil(total / per_page), and 1 on an empty collection. Null when the request did not send page. ## PageOptionalTotal - per_page (integer, required): The page size that was applied. - next_cursor (string or null, required): Cursor for the next page, or null on the last one. - prev_cursor (string or null, required): Cursor for the previous page, or null on the first one. - total (integer or null, required): The total number of matching rows; null unless with_total=1 was sent. ## PageLinks - self (url, required): This page. - next (url or null, required): The next page, or null on the last one. - prev (url or null, required): The previous page, or null on the first one. ## Ref A reference to a record by its id. The full object appears when the relation is included. - id (integer, required) ## TagSummary A tag, as referenced from another resource. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## TagSummaryInclude - data (array of TagSummary ≤ 50, required) - meta (object, required) - count (integer ≥ 0, optional) - truncated (boolean, required) ## Example ```json { "data": [ { "id": 1, "issue_key": "ACM-123", "title": "…", "priority": "highest", "due_date": "2026-03-04", "estimation": 1, "remaining_estimate": 1, "order": 1, "column_order": 1, "version": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "project_id": 1, "task_id": 1, "workflow_status_id": 1, "work_type_id": 1, "sprint_id": 1, "parent_id": 1, "assignee_id": 1, "reporter_id": 1, "web_url": "…", "workflow_status": { "id": 1 }, "parent": null, "assignee": null, "reporter": { "id": 1 }, "work_type": { "id": 1 }, "sprint": null, "comments_count": 1, "subtasks_count": 1, "attachments_count": 1, "child_work_items_count": 1, "labels_count": 1, "drive_items_count": 1, "links_count": 1, "links": { "…": "8 fields of WorkItemLinks" }, "…": "4 optional fields left out" } ], "meta": { "page": { "per_page": 1, "next_cursor": "…", "prev_cursor": "…", "total": 1 }, "warnings": [ { "…": "6 fields of warnings" } ] }, "links": { "self": "https:///api/v2/work-items/1", "next": "https:///api/v2/work-items/1", "prev": "https:///api/v2/work-items/1" } } ``` --- # Absence An absence: the period an employee was away from work, booked for them by HR. An absence with no `end` is still running and reaches through now everywhere it is read, so planning capacity and reporting both keep counting it until someone closes it. The free-text note absences once carried was purged as health data and is not exposed here. ## Used by - GET /api/v2/absences: List absences (AbsenceList) - POST /api/v2/absences: Book an absence (Absence) - GET /api/v2/absences/{absence}: Read an absence (Absence) - PATCH /api/v2/absences/{absence}: Update an absence (Absence) ## Absence fields - id (integer, required): Unique identifier. - user_id (integer, required): The employee this absence is for. Writing it requires the `absences::manage` permission. - user (Ref or UserSummary, required): The employee the absence is for. Renders as an `{"id": N}` reference unless expanded, and stays a reference when the employee has since been deleted. - manager_id (integer or null, required): Manager who filed or approved the absence, or `null` when none was recorded. The foreign key nulls on delete, so removing the manager leaves the absence behind unattributed. - manager (Ref or UserSummary or null, required): The manager who filed or approved the absence; absent when none was recorded. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the absence begins. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the absence ends, or `null` while it is still running. An ongoing absence reaches through now everywhere it is read (planning capacity and reporting both count it), and that reach is uncapped, so an absence nobody closed keeps consuming capacity. RFC 3339 UTC. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (AbsenceLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### AbsenceList What a collection returns, and what `?view=summary` returns on a single record. 10 fields. Identical to the detail object above. ### AbsenceSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: user_id, user, manager_id, manager, created_at, updated_at, links ### AbsenceLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "user_id": 1, "user": { "id": 1 }, "manager_id": 1, "manager": null, "start": "2026-03-02T08:00:00Z", "end": "2026-03-06T17:00:00Z", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Budget A budget: a named pot of minutes on a project that tasks are filed under and time is logged against, optionally filed in an organisation-wide budget group. Deleting one is permanent and takes its tasks with it; a budget with logged time cannot be deleted. ## Used by - GET /api/v2/budgets: List budgets (BudgetList) - POST /api/v2/budgets: Create a budget (Budget) - GET /api/v2/budgets/{budget}: Read a budget (Budget) - PATCH /api/v2/budgets/{budget}: Update a budget (Budget) ## Budget fields - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. Settable at create time only; a budget cannot be moved to another project. - budget_group_id (integer or null, required): Budget group this budget is filed under, `null` when ungrouped. - order_column (integer, required, server-assigned): Position within the project as arranged in plnnrly. Set on create to the highest existing position plus one, and renumbered 1..n per project whenever the project's budget page is saved in plnnrly. Not writable. - description (string ≤ 255, required): Budget description as shown in the UI. This is the budget's label and the only field `?q=` searches; a budget has no title. - start_date (date (YYYY-MM-DD) or null, required): First day the budget covers, `null` when it has no start. - end_date (date (YYYY-MM-DD) or null, required): Last day the budget covers, `null` when it has no end. - budget (integer, required): Budgeted amount of work, in minutes. - spendable (integer, required): The portion of the budget that may be allocated to tasks, in minutes. A stored value, never decremented by logged time. - status (string, required): Lifecycle status of the budget. - is_open (boolean, required): Whether the budget is open-ended: when `true` the spendable cap is ignored, plnnrly shows "open" in place of a minute total and the over-budget warning is skipped. - is_billable (boolean, required): Whether time logged on this budget is billable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this budget belongs to. - budget_group (Ref or BudgetGroupSummary or null, required): The budget group this budget is filed under. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this budget that the caller may see. - links (BudgetLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks filed under this budget. ## Variants ### BudgetList What a collection returns, and what `?view=summary` returns on a single record. 18 fields. Identical to the detail object above. ### BudgetSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: project_id, budget_group_id, order_column, start_date, end_date, budget, spendable, status, is_open, is_billable, created_at, updated_at, project, budget_group, tasks_count, links ### BudgetLinks The `links` object on this resource. Keys: self, tasks ## Example ```json { "id": 1, "project_id": 1, "budget_group_id": 1, "order_column": 1, "description": "Front-end development", "start_date": "2026-03-04", "end_date": "2026-03-04", "budget": 4800, "spendable": 4200, "status": "active", "is_open": false, "is_billable": true, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "project": { "id": 1 }, "budget_group": null, "tasks_count": 1, "links": { "self": "https:///api/v2/work-items/1", "tasks": "https:///api/v2/work-items/1" } } ``` --- # BudgetGroup A budget group: the organisation-wide category budgets are filed under, carrying the icon plnnrly shows beside them. Deleting one ungroups its budgets permanently. ## Used by - GET /api/v2/budget-groups: List budget groups (BudgetGroupList) - POST /api/v2/budget-groups: Create a budget group (BudgetGroup) - GET /api/v2/budget-groups/{budget_group}: Read a budget group (BudgetGroup) - PATCH /api/v2/budget-groups/{budget_group}: Update a budget group (BudgetGroup) ## BudgetGroup fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Budget group name as shown in the UI. This is the default sort key. - icon (string ≤ 255, required): Icon identifier rendered beside the group, e.g. `heroicon-o-rectangle-stack`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budgets_count (integer ≥ 0, required): Number of budgets filed under this group. - links (BudgetGroupLinks, required) - self (url, required): The canonical URI of this record. - budgets (url, required): Budgets filed under this group. ## Variants ### BudgetGroupList What a collection returns, and what `?view=summary` returns on a single record. 7 fields. Identical to the detail object above. ### BudgetGroupSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: icon, created_at, updated_at, budgets_count, links ### BudgetGroupLinks The `links` object on this resource. Keys: self, budgets ## Example ```json { "id": 1, "name": "Development", "icon": "heroicon-o-code-bracket", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "budgets_count": 1, "links": { "self": "https:///api/v2/work-items/1", "budgets": "https:///api/v2/work-items/1" } } ``` --- # Checkin A checkin: one attendance record for one employee, opened when they clock in and closed when they clock out. A `null` `checked_out_at` means the checkin is still open: that is the only thing distinguishing an in-progress day from a finished one, and `filter[checked_out_at][null]=true` is how a client asks for it. Both timestamps are UTC; rendering them in the employee's own zone is a client concern. ## Used by - GET /api/v2/checkins: List checkins (CheckinList) - POST /api/v2/checkins: Check in (Checkin) - GET /api/v2/checkins/{checkin}: Read a checkin (Checkin) - PATCH /api/v2/checkins/{checkin}: Check out, or correct a checkin (Checkin) ## Checkin fields - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this checkin belongs to. Assigned from the authenticated user on create and never writable. - user (Ref or UserSummary, required): The employee this checkin belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - checked_in_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the employee clocked in. - checked_out_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the employee clocked out, or `null` while the checkin is still open. A `null` value marks the checkin plnnrly shows as active. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (CheckinLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### CheckinList What a collection returns, and what `?view=summary` returns on a single record. 8 fields. Identical to the detail object above. ### CheckinSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: user_id, user, checked_out_at, created_at, updated_at, links ### CheckinLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "user_id": 1, "user": { "id": 1 }, "checked_in_at": "2026-03-04T09:15:00Z", "checked_out_at": "2026-03-04T09:15:00Z", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Comment A comment on a work item or on a drive item: the rich-text body one user posted to a thread, with its author and its timestamps. Comments are reached through their parent (GET `work-items/{work_item}/comments`) or by id on the flat item path, and the parent is named by the `commentable_type` / `commentable_id` pair rather than by an expandable relation. Editing and deleting are author-only. ## Used by - GET /api/v2/comments/{comment}: Read a comment (Comment) - PATCH /api/v2/comments/{comment}: Edit a comment (Comment) - GET /api/v2/drive-items/{drive_item}/comments: List a drive item's comments (CommentList) - POST /api/v2/drive-items/{drive_item}/comments: Post a comment on a drive item (Comment) - GET /api/v2/work-items/{work_item}/comments: List a work item's comments (CommentList) - POST /api/v2/work-items/{work_item}/comments: Post a comment on a work item (Comment) ## Comment fields - id (integer, required): Unique identifier. - body (html, required): Comment body as rich text. HTML from the plnnrly editor, sanitised on the way out, so what you read back can differ from the bytes you sent. Mention spans (``) survive untouched and may be posted back verbatim; constructing a new mention is not supported in v2.0. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. Renders as `{"id": N}` unless `?include=author` expands it, which additionally requires the users prefix's read grant and a token covering it. - commentable_type (string, required): The kind of record `commentable` points at; it says which branch of `commentable` applies. - commentable_id (integer, required): The id of the record `commentable` points at. - commentable (Ref, required): The record the comment hangs off, as a reference. Look it up from `commentable_type` and `commentable_id` with `GET /api/v2/work-items/{id}` or GET `/api/v2/drive-items/{id}`. It is not expandable: `?include=commentable` is a `400`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync, and as the revision the strong `ETag` is derived from. - links (CommentLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### CommentList What a collection returns, and what `?view=summary` returns on a single record. 11 fields. Identical to the detail object above. ### CommentSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 5 fields. Omits: body, commentable_type, commentable_id, commentable, updated_at, links ### CommentLinks The `links` object on this resource. Keys: self ## Polymorphic fields - author: discriminated on `author_type` - commentable: discriminated on `commentable_type` ## Example ```json { "id": 1, "body": "

Section 3 needs the new pricing table.

", "author_type": "user", "author_id": 1, "author": { "id": 1 }, "commentable_type": "work_item", "commentable_id": 1, "commentable": { "id": 1 }, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # CompanyBranch A company branch: one of the organisation's own locations, used to group the customers a branch is responsible for. Deleting one un-assigns its customers rather than blocking. ## Used by - GET /api/v2/company-branches: List company branches (CompanyBranchList) - POST /api/v2/company-branches: Create a company branch (CompanyBranch) - GET /api/v2/company-branches/{company_branch}: Read a company branch (CompanyBranch) - PATCH /api/v2/company-branches/{company_branch}: Update a company branch (CompanyBranch) ## CompanyBranch fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Branch name as shown in the UI, e.g. Rotterdam. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers assigned to this branch. - links (CompanyBranchLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers assigned to this branch. ## Variants ### CompanyBranchList What a collection returns, and what `?view=summary` returns on a single record. 6 fields. Identical to the detail object above. ### CompanyBranchSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: created_at, updated_at, customers_count, links ### CompanyBranchLinks The `links` object on this resource. Keys: self, customers ## Example ```json { "id": 1, "name": "Rotterdam", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "customers_count": 1, "links": { "self": "https:///api/v2/work-items/1", "customers": "https:///api/v2/work-items/1" } } ``` --- # Contact A contact person: either a customer's contact (the CRM address book) or an employee's emergency contact, distinguished by `contactable_type`. Emergency contacts are personal data: they are visible and writable only to the employee they belong to, whatever the customers permission. Contacts are not soft-deleted, so deleting one is permanent. ## Used by - GET /api/v2/contacts: List contacts (ContactList) - POST /api/v2/contacts: Create a contact (Contact) - GET /api/v2/contacts/{contact}: Read a contact (Contact) - PATCH /api/v2/contacts/{contact}: Update a contact (Contact) ## Contact fields - id (integer, required): Unique identifier. - contactable_type (string, required): The kind of record `contactable` points at; it says which branch of `contactable` applies. - contactable_id (integer, required): The id of the record `contactable` points at. - contactable (Ref or CustomerSummary or UserSummary, required): The record this contact belongs to: a customer for a CRM contact, a user for an employee's emergency contact. Rendered as {"id": N} until named in `?include=contactable`, and still as {"id": N} where the caller may not read the parent's prefix. `contactable_type` and `contactable_id` are always present, so the parent can be found without the include, and the reference form shows nothing `contactable_id` does not. - contact_role_id (integer or null, required): Assigned contact role, or `null`. Deleting a role unassigns it rather than refusing. - role (Ref or ContactRoleSummary or null, required): The contact's role. Rendered as {"id": N} until named in `?include=role`, and `null` where no role is assigned. - name (string ≤ 255, required): Contact name. This is the default sort key and one of the fields `?q=` searches. - email (string or null ≤ 255, required): Email address. The second field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. plnnrly pre-fills this from the customer when adding a contact, but the API fills in no default for it. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - position (integer or null, required): Display order within one parent, ascending. Null where unset. - comment (html or null, required): Free-form note, sanitised HTML. The emergency-contact form uses it for the relationship to the employee, e.g. Partner. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ContactLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### ContactList What a collection returns, and what `?view=summary` returns on a single record. 18 fields. Identical to the detail object above. ### ContactLinks The `links` object on this resource. Keys: self ## Polymorphic fields - contactable: discriminated on `contactable_type` ## Example ```json { "id": 1, "contactable_type": "customer", "contactable_id": 1, "contactable": { "id": 1 }, "contact_role_id": 1, "role": null, "name": "Marieke de Vries", "email": "marieke@example.test", "phone": "+31 20 123 4567", "address": "…", "zip": "…", "city": "…", "country": "…", "position": 1, "comment": "

Prefers email.

", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # ContactRole A contact role: the organisation-wide list a contact's function is picked from, e.g. Technical contact or Invoicing. Deleting one unassigns it from every contact rather than refusing. ## Used by - GET /api/v2/contact-roles: List contact roles (ContactRoleList) - POST /api/v2/contact-roles: Create a contact role (ContactRole) - GET /api/v2/contact-roles/{contact_role}: Read a contact role (ContactRole) - PATCH /api/v2/contact-roles/{contact_role}: Update a contact role (ContactRole) ## ContactRole fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Role name as shown in the UI, unique across the organisation. This is the default sort key and the field `?q=` searches. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - contacts_count (integer ≥ 0, required): Number of contacts assigned this role. - links (ContactRoleLinks, required) - self (url, required): The canonical URI of this record. - contacts (url, required): Contacts assigned this role. ## Variants ### ContactRoleList What a collection returns, and what `?view=summary` returns on a single record. 6 fields. Identical to the detail object above. ### ContactRoleSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: created_at, updated_at, contacts_count, links ### ContactRoleLinks The `links` object on this resource. Keys: self, contacts ## Example ```json { "id": 1, "name": "Invoicing", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "contacts_count": 1, "links": { "self": "https:///api/v2/work-items/1", "contacts": "https:///api/v2/work-items/1" } } ``` --- # Customer A customer: the CRM record every project, contact, subscription and sales invoice hangs off. Soft-deleted, and deleting one cascades to its projects. ## Used by - GET /api/v2/customers: List customers (CustomerList) - POST /api/v2/customers: Create a customer (Customer) - GET /api/v2/customers/{customer}: Read a customer (Customer) - PATCH /api/v2/customers/{customer}: Update a customer (Customer) - POST /api/v2/customers/{customer}/actions/restore: Restore a deleted customer (Customer) ## Customer fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Customer name. This is the default sort key and one of the fields `?q=` searches. - code (string ≤ 255, required): Short customer code, e.g. ACM. Prefixes every work item's issue key, is enforced unique on write, and is one of the fields `?q=` searches. - color (string ≤ 255, required): Chip colour as a CSS hex triplet or sextet, e.g. #1d4ed8. A stored value that is not valid hex reads back as #6b7280. - status (string, required): Where the customer sits in the sales lifecycle. - email (string or null ≤ 255, required): Primary email address. The third field `?q=` searches; not unique. - phone (string or null ≤ 255, required): Phone number. - address (string or null ≤ 255, required): Street address. Writing this, zip or city re-geocodes lat and lng. - zip (string or null ≤ 255, required): Postal code. - city (string or null ≤ 255, required): City. - country (string or null ≤ 255, required): Country as an ISO 3166-1 alpha-2 code, e.g. NL, as plnnrly writes it. Older records may hold any string. - lat (string or null ≤ 255, required, server-assigned): Latitude, geocoded from the address whenever address, zip or city changes. Not writable. - lng (string or null ≤ 255, required, server-assigned): Longitude, geocoded from the address whenever address, zip or city changes. Not writable. - founded_at (date (YYYY-MM-DD) or null, required): Day the customer's company was founded. - customer_since (date (YYYY-MM-DD) or null, required): Day this customer became a customer. - is_vat_exempt (boolean, required): Whether invoices to this customer are issued without VAT. - payment_days (integer or null, required, server-assigned): Payment term in days, as held by the accounting integration. Read by the invoice composer; written only by the Exact sync. - debtor_code (string or null ≤ 255, required): Debtor number in the accounting system. Writing it requires `billing_settings::update`. - exact_account_id (string or null ≤ 255, required, server-assigned): Account identifier in Exact Online, written by the sync. - exact_synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the Exact sync last handled this customer, or `null` if never. - exact_keep_local_address (boolean, required, server-assigned): Whether the Exact sync leaves this customer's local address alone although it differs from the one in Exact. Set when a user accepts the difference on the Exact customers screen in plnnrly; not writable. - account_manager_id (integer or null, required): Responsible account manager. Required on create; unassigned when the user is deleted. - customer_category_id (integer or null, required): Customer category, or `null`. - company_type_id (integer or null, required): Company type, or `null`. Company types are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_size_id (integer or null, required): Company size band, or `null`. Company sizes are your organisation's own list, maintained in plnnrly and not exposed as a collection. - company_branch_id (integer or null, required): Industry branch, or `null`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_category (Ref or CustomerCategorySummary or null, required): The customer's category. Rendered as {"id": N} until named in `?include=customer_category`, and `null` where none is assigned. - company_branch (Ref or CompanyBranchSummary or null, required): The customer's industry branch. Rendered as {"id": N} until named in `?include=company_branch`, and `null` where none is assigned. - projects_count (integer ≥ 0, required): Number of live projects for this customer. Soft-deleted projects are not counted. - contacts_count (integer ≥ 0, required): Number of contacts on this customer. - tags_count (integer ≥ 0, required): Number of tags on this customer. - links (CustomerLinks, required) - self (url, required): The canonical URI of this record. - projects (url, required): Projects for this customer. - contacts (url, required): Contacts on this customer. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this customer. A customer takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Variants ### CustomerList What a collection returns, and what `?view=summary` returns on a single record. 36 fields. Identical to the detail object above. ### CustomerSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: color, status, email, phone, address, zip, city, country, lat, lng, founded_at, customer_since, is_vat_exempt, payment_days, debtor_code, exact_account_id, exact_synced_at, exact_keep_local_address, account_manager_id, customer_category_id, company_type_id, company_size_id, company_branch_id, created_at, updated_at, deleted_at, customer_category, company_branch, tags, projects_count, contacts_count, tags_count, links ### CustomerLinks The `links` object on this resource. Keys: self, projects, contacts ## Columns deliberately not published - exact_sync_issues: Holds free-form sync messages with no stable shape, so it has no honest published type. exact_synced_at is published instead, so a client can still tell whether a sync has run. - location: Not stored on its own: it combines lat and lng, both of which are published as their own read-only fields. ## Example ```json { "id": 1, "name": "Acme Industries", "code": "ACM", "color": "#1d4ed8", "status": "active", "email": "hello@acme.test", "phone": "…", "address": "Keizersgracht 1", "zip": "1015 CJ", "city": "Amsterdam", "country": "NL", "lat": "…", "lng": "…", "founded_at": "2026-03-04", "customer_since": "2026-01-15", "is_vat_exempt": false, "payment_days": 1, "debtor_code": "…", "exact_account_id": "…", "exact_synced_at": "2026-03-04T09:15:00Z", "exact_keep_local_address": false, "account_manager_id": 1, "customer_category_id": 1, "company_type_id": 1, "company_size_id": 1, "company_branch_id": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "customer_category": null, "company_branch": null, "tags": { "data": [ { "…": "2 fields of TagSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "projects_count": 1, "contacts_count": 1, "tags_count": 1, "links": { "self": "https:///api/v2/work-items/1", "projects": "https:///api/v2/work-items/1", "contacts": "https:///api/v2/work-items/1" } } ``` --- # CustomerCategory A customer category: the hand-maintained taxonomy customers are grouped by, used for segmentation and reporting. Deleting one un-categorises its customers rather than blocking. ## Used by - GET /api/v2/customer-categories: List customer categories (CustomerCategoryList) - POST /api/v2/customer-categories: Create a customer category (CustomerCategory) - GET /api/v2/customer-categories/{customer_category}: Read a customer category (CustomerCategory) - PATCH /api/v2/customer-categories/{customer_category}: Update a customer category (CustomerCategory) ## CustomerCategory fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Category name as shown in the UI, e.g. Enterprise. - description (string or null, required, multiline): Free-text note on what belongs in this category. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - customers_count (integer ≥ 0, required): Number of customers in this category. - links (CustomerCategoryLinks, required) - self (url, required): The canonical URI of this record. - customers (url, required): Customers in this category. ## Variants ### CustomerCategoryList What a collection returns, and what `?view=summary` returns on a single record. 7 fields. Identical to the detail object above. ### CustomerCategorySummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: description, created_at, updated_at, customers_count, links ### CustomerCategoryLinks The `links` object on this resource. Keys: self, customers ## Example ```json { "id": 1, "name": "Enterprise", "description": "Accounts over 500 seats.", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "customers_count": 1, "links": { "self": "https:///api/v2/work-items/1", "customers": "https:///api/v2/work-items/1" } } ``` --- # CustomerSubscription A recurring agreement with one customer: what it covers, the interval it is billed at, when it started and when it is next invoiced. Its lines live on the items sub-resource, and the renewal run turns a due subscription into a sales invoice. A subscription with no next invoice date is never invoiced automatically. ## Used by - GET /api/v2/customer-subscriptions: List customer subscriptions (CustomerSubscriptionList) - POST /api/v2/customer-subscriptions: Create a customer subscription (CustomerSubscription) - GET /api/v2/customer-subscriptions/{customer_subscription}: Read a customer subscription (CustomerSubscription) - PATCH /api/v2/customer-subscriptions/{customer_subscription}: Update a customer subscription (CustomerSubscription) - POST /api/v2/customer-subscriptions/{customer_subscription}/actions/restore: Restore a deleted customer subscription (CustomerSubscription) ## CustomerSubscription fields - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this subscription belongs to. - customer (Ref or CustomerSummary, required): The customer this subscription belongs to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - subscription_type_id (integer, required): Subscription type this subscription is an instance of. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this subscription is an instance of, which is also where its displayed name comes from. Expanding it with `?include=subscription_type` additionally requires the `subscription_types::read` permission and a token scope covering the `subscription_types` prefix. - description (string or null, required, multiline): What this subscription covers, as free text. This is the field `?q=` searches, and it is also what a generated invoice line falls back to. - status (string, required): Lifecycle status. Only an active subscription is picked up by the renewal run; the other three are inert. - billing_interval (string, required): How often the subscription is invoiced. one-time forces `auto_renew` to `false`, because a one-off has no next period to move on to. - starts_at (date (YYYY-MM-DD), required): First day the subscription runs from, as a zone-free calendar date. Newest first is the default sort. - next_invoice_date (date (YYYY-MM-DD) or null, required): First day of the period to be invoiced next. Omit it on create and the server fills it with `starts_at`, so the first period is billable; the renewal run advances it by one interval each time it invoices. Null makes the subscription invisible to that run (it is skipped, silently), so a subscription that should never auto-invoice is expressed this way. - cancelled_at (date (YYYY-MM-DD) or null, required): Day the subscription was cancelled, `null` while it has not been. It is a record of the cancellation date only: nothing derives status from it. - expires_at (date (YYYY-MM-DD) or null, required): Day the subscription runs out, `null` when it is open-ended. - auto_renew (boolean, required): Whether the renewal run may invoice this subscription again. A one-time subscription is always `false`: it is forced on every save, so sending `true` together with `billing_interval=one-time` succeeds and comes back as `false` rather than failing. - exact_subscription_id (string or null ≤ 255, required, server-assigned): Exact Online subscription this one maps to, by Exact's own external identifier, not by a local id. Written by the sync; `null` when the subscription has never been synced. - last_invoiced_at (date (YYYY-MM-DD) or null, required, server-assigned): Day the subscription was last invoiced, `null` until it has been. Maintained by the renewal run. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a customer subscription has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of lines on this subscription. - links (CustomerSubscriptionLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Lines on this subscription. ## Variants ### CustomerSubscriptionList What a collection returns, and what `?view=summary` returns on a single record. 20 fields. Identical to the detail object above. ### CustomerSubscriptionSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: customer, subscription_type_id, subscription_type, description, status, billing_interval, next_invoice_date, cancelled_at, expires_at, auto_renew, exact_subscription_id, last_invoiced_at, created_at, updated_at, deleted_at, items_count, links ### CustomerSubscriptionLinks The `links` object on this resource. Keys: self, items ## Example ```json { "id": 1, "customer_id": 1, "customer": { "id": 1 }, "subscription_type_id": 1, "subscription_type": { "id": 1 }, "description": "Managed hosting and maintenance.", "status": "pending", "billing_interval": "yearly", "starts_at": "2026-07-01", "next_invoice_date": "2026-03-04", "cancelled_at": "2026-03-04", "expires_at": "2026-03-04", "auto_renew": true, "exact_subscription_id": "…", "last_invoiced_at": "2026-03-04", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "items_count": 1, "links": { "self": "https:///api/v2/work-items/1", "items": "https:///api/v2/work-items/1" } } ``` --- # DiscoveryEntry One resource this API exposes: where it lives, what it is called in the permission system, which schemas describe it and which operations it registers. ## Used by - GET /api/v2/: List the resources this API exposes (DiscoveryEntryList) ## DiscoveryEntry fields - name (string, required): Human-readable name of the resource collection. - path (string or null, required): Absolute URL of the collection endpoint, or `null` where this resource registers no flat collection and is reached only through a parent or by id. - item_path (string or null, required): Templated absolute URL of the single-resource endpoint, with the path parameter left as a `{placeholder}`. Null where the resource has no flat route at all. - permission_prefix (string, required): The permission prefix this resource is gated on. It is the value after the class in an `api:{class}:{prefix}` token ability. - summary_schema (string, required): JSON pointer to the OpenAPI component describing this resource in the `summary` view, which is what a collection returns by default. - detail_schema (string, required): JSON pointer to the OpenAPI component describing the `detail` view, which is what a single-resource read and every write return. - operations (array of string, required): The operations this resource registers, as `index`, `store`, `show`, `update`, `destroy` and `actions.{verb}`. A nested operation keeps its full identifier, so `work-items.comments.index` appears verbatim. ## Variants ### DiscoveryEntryList What a collection returns, and what `?view=summary` returns on a single record. 7 fields. Identical to the detail object above. ## Example ```json { "name": "…", "path": "…", "item_path": "…", "permission_prefix": "…", "summary_schema": "…", "detail_schema": "…", "operations": [ "…" ] } ``` --- # DriveDocument The body of a drive item of kind `document`: the rich-editor content plnnrly opens when you click a document in the file browser. It is stored separately from the item, so the item's own metadata (title, folder, tags, owner, access) lives on the drive item and is not repeated here. The body is exchanged as GitHub-Flavored Markdown, with a sanitised-HTML projection available on demand for the colours, highlights, alignment and mentions Markdown cannot express. Access is inherited whole from the owning drive item: a caller who may read that item may read its body, and a caller who may edit it may write one. ## Used by - GET /api/v2/drive-documents: List document bodies (DriveDocumentList) - POST /api/v2/drive-documents: Create a document body (DriveDocument) - GET /api/v2/drive-documents/{drive_document}: Read a document body (DriveDocument) - PATCH /api/v2/drive-documents/{drive_document}: Write a document body (DriveDocument) ## DriveDocument fields - id (integer, required): Id of the document body, not of the drive item it belongs to. The two are numbered separately. - drive_item_id (integer, required): The drive item this body belongs to. Set on create and immutable afterwards; read the item itself from `GET /api/v2/drive-items/{drive_item}`. - content (markdown or null, required, detail only): The document body as GitHub-Flavored Markdown: headings, lists, task lists (`- [ ]`), tables, code fences, links and images all survive. Detail-only: pass `?view=detail` to get it on the collection. Markdown cannot express text colours, highlights, alignment or @mentions, so read `?include=content_html` first if the document may contain them: a read of `content` followed by a PATCH of it discards all four permanently. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. Typing in the collaborative editor moves this, not the drive item's own `updated_at`. - links (DriveDocumentLinks, required) - self (url, required): The canonical URI of this record. - content_html (html or null, optional, on request): The same document as sanitised HTML, preserving the text colours, highlights, alignment and @mentions Markdown cannot carry. Mention spans round-trip unchanged; constructing a new one is out of scope for v2.0. Only on `?view=detail`: content ## Variants ### DriveDocumentList What a collection returns, and what `?view=summary` returns on a single record. 6 fields. Omits: content ### DriveDocumentLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "drive_item_id": 1, "content": "# Kick-off\n\n- [ ] Confirm scope with the client\n- [x] Book the room\n", "content_html": "

…

", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # DriveFolder A folder in a drive: the tree plnnrly's file browser draws under a project, a customer, the organisation drive or a user's personal drive. A folder holds drive items and other folders, and it is where the drive's access rules are anchored: an item or a subfolder that inherits permissions takes them from the nearest ancestor folder that does not. ## Used by - GET /api/v2/drive-folders: List drive folders (DriveFolderList) - POST /api/v2/drive-folders: Create a drive folder (DriveFolder) - GET /api/v2/drive-folders/{drive_folder}: Read a drive folder (DriveFolder) - PATCH /api/v2/drive-folders/{drive_folder}: Update a drive folder (DriveFolder) ## DriveFolder fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Folder name as shown in the drive tree. - parent_id (integer or null, required): Folder this one sits in, or `null` at the drive root. - parent_folder (Ref or DriveFolderSummary or null, required): The folder this one sits in; absent for folders at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this folder lives in. An organisation-owned folder renders as a bare {"id": N} reference. - order (integer, required, server-assigned): Sort position within the drive and level, as shown in the drive tree. Positions count per drive and level, so the sequence is only meaningful once the collection is narrowed to one drive and one level. It is not assigned on create, so a folder created through the API stays at 0 until it is positioned in plnnrly; reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - inherits_permissions (boolean, required): Whether this folder takes its permissions from its parent. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this folder, after the permission for the owner type, the access inherited from folders and any per-folder grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - drive_items_count (integer ≥ 0, required): Live drive items directly in this folder that the caller may see. Archived items are not counted, and neither are items in subfolders. - links (DriveFolderLinks, required) - self (url, required): The canonical URI of this record. - drive_items (url, required): Drive items directly in this folder. ## Variants ### DriveFolderList What a collection returns, and what `?view=summary` returns on a single record. 16 fields. Identical to the detail object above. ### DriveFolderSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: parent_id, parent_folder, owner_type, owner_id, owner, order, created_by, inherits_permissions, general_access, access, created_at, updated_at, drive_items_count, links ### DriveFolderLinks The `links` object on this resource. Keys: self, drive_items ## Polymorphic fields - owner: discriminated on `owner_type` ## Example ```json { "id": 1, "name": "Kick-off", "parent_id": 1, "parent_folder": null, "owner_type": "project", "owner_id": 1, "owner": { "id": 1 }, "order": 1, "created_by": 1, "inherits_permissions": false, "general_access": "everyone_edit", "access": "view", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "drive_items_count": 1, "links": { "self": "https:///api/v2/work-items/1", "drive_items": "https:///api/v2/work-items/1" } } ``` --- # DriveItem A file or document in a drive: the rows plnnrly's file browser lists under a project, a customer, the organisation drive or a user's personal drive. A drive item is the envelope (title, folder, tags, sharing tier), and its body, where it has one, lives on the drive document it points at through `content_record`. Access is decided per item: the permission for the owner type, then the folders it inherits from, then any per-item grant. ## Used by - GET /api/v2/drive-items: List drive items (DriveItemList) - POST /api/v2/drive-items: Create a drive item (DriveItem) - GET /api/v2/drive-items/{drive_item}: Read a drive item (DriveItem) - PATCH /api/v2/drive-items/{drive_item}: Update a drive item (DriveItem) - POST /api/v2/drive-items/{drive_item}/actions/restore: Restore an archived drive item (DriveItem) ## DriveItem fields - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - group (string or null ≤ 255, required): Free-text grouping label shown in the drive tree. - kind (string, required): What this item is. Everything created through the API is a `document`; the other kinds come from uploads in plnnrly. - source (string, required): Where the item came from: `builtin` for an editor document, `upload` for a file uploaded in plnnrly. - folder_id (integer or null, required): Folder this item sits in, or `null` at the drive root. - drive_folder (Ref or DriveFolderSummary or null, required): The folder this item sits in; absent for items at the drive root. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref or ProjectSummary or CustomerSummary or UserSummary, required): The project, customer, organisation or user whose drive this item lives in. An organisation-owned item renders as a bare {"id": N} reference. - content_record_type (string or null, required): The kind of record `content_record` points at; it says which branch of `content_record` applies. - content_record_id (integer or null, required): The id of the record `content_record` points at. - content_record (Ref or null, required): The body this item points at: a drive document for an editor document, `null` for an item with no body yet. Renders as a bare {"id": N} reference; read the body from `GET /api/v2/drive-documents/{drive_document}`. - order_column (integer, required, server-assigned): Sort position, assigned by the server. Positions count per drive and folder, so the sequence is only meaningful once the collection is narrowed to one drive and one folder. Reordering is not exposed in v2.0. - created_by (integer or null, required, server-assigned): Author; always the authenticated user at create time. Null on legacy rows and where the author has since been deleted. - archived_by (integer or null, required, server-assigned): The user who archived this item; set on delete and cleared on restore. - inherits_permissions (boolean, required): Whether this item takes its permissions from its folder. - general_access (string, required): Baseline sharing tier, consulted only while `inherits_permissions` is `false`. - access (string or null, required): The calling user's effective access level on this item, after the permission for the owner type, the access inherited from folders and any per-item grant. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When this item was archived, or `null` while it is live. - comments_count (integer ≥ 0, required): Comments on this item. - files_count (integer ≥ 0, required): Files attached to this item, counting the attachments list in plnnrly, the single uploaded file and the images embedded in the document body. - tags_count (integer ≥ 0, required): Tags on this item. - links (DriveItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this drive item. - files (url, required): Files stored on this drive item. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this item. An item takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. ## Variants ### DriveItemList What a collection returns, and what `?view=summary` returns on a single record. 27 fields. Identical to the detail object above. ### DriveItemSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 5 fields. Omits: group, kind, source, folder_id, drive_folder, content_record_type, content_record_id, content_record, order_column, created_by, archived_by, inherits_permissions, general_access, access, created_at, updated_at, deleted_at, comments_count, files_count, tags_count, tags, links Narrows owner: Ref or ProjectSummary or CustomerSummary or UserSummary -> Ref ### DriveItemLinks The `links` object on this resource. Keys: self, comments, files ## Polymorphic fields - owner: discriminated on `owner_type` - content_record: discriminated on `content_record_type` ## Example ```json { "id": 1, "title": "Kick-off notes", "group": "…", "kind": "document", "source": "builtin", "folder_id": 1, "drive_folder": null, "owner_type": "project", "owner_id": 1, "owner": { "id": 1 }, "content_record_type": "drive_document", "content_record_id": 1, "content_record": null, "order_column": 1, "created_by": 1, "archived_by": 1, "inherits_permissions": false, "general_access": "everyone_edit", "access": "view", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "comments_count": 1, "files_count": 1, "tags_count": 1, "tags": { "data": [ { "…": "2 fields of TagSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "links": { "self": "https:///api/v2/work-items/1", "comments": "https:///api/v2/work-items/1", "files": "https:///api/v2/work-items/1" } } ``` --- # Event A planning event: one block on a user's agenda, optionally linked to a kanban task so time can be logged against it. An event is either a one-off or the master of a recurring series, in which case the recurrence fields describe how its occurrences are generated. The occurrences are rendered from those fields and are not rows of their own, except where one was separated in plnnrly. ## Used by - GET /api/v2/events: List events (EventList) - POST /api/v2/events: Create an event (Event) - GET /api/v2/events/{event}: Read an event (Event) - PATCH /api/v2/events/{event}: Update an event (Event) - POST /api/v2/events/{event}/actions/restore: Restore a deleted event (Event) ## Event fields - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Whose agenda the event is on. Always the authenticated user at creation time; not writable. - task_id (integer or null, required): Kanban task this event is planned against, or `null` for a plain calendar block. Only an event with a task can have time logged on it. - parent_event_id (integer or null, required, server-assigned): Master event, when this row is a separated occurrence of a recurring series. Set by the server; not writable. - occurrence_date (date (YYYY-MM-DD) or null, required, server-assigned): The date of the series occurrence this row replaces. Set by the server; not writable. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event starts. RFC 3339 UTC. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the event ends. RFC 3339 UTC. - title (string ≤ 255, required): Event title, as shown on the calendar tile. - description (html or null, required, detail only): Free-text description, authored in the rich editor. HTML, sanitised on the way in and on the way out; `@`-mention spans are preserved, and changing them is what notifies the people mentioned. - is_all_day (boolean, required): Whether the event occupies whole days rather than a time range. An all-day event is measured against the owner's working week rather than against start and end. - is_recurring (boolean, required): Whether this row is the master of a recurring series. When `true`, the recurrence fields describe how occurrences are generated. - recurrence_interval (integer or null, required): How many frequency units pass between occurrences: 2 with a weekly frequency is every other week. - recurrence_frequency (string or null, required): The unit the series repeats in. - recurrence_pattern (string or null, required): How a monthly or yearly series positions its occurrence within the month. Ignored for a daily or weekly frequency. - recurrence_start (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): First moment the series may generate an occurrence. - recurrence_end (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Last moment the series may generate an occurrence; `null` for an open-ended series. - selected_weekdays (array of string or null, required, detail only): Weekdays a recurring event falls on. Applies to a weekly frequency; ignored otherwise. - recurrence_exceptions (array of string or null, required, detail only): Dates on which this recurrence does not occur. plnnrly adds one whenever a single occurrence is separated or deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the event was deleted, or `null` while it is live. Reach a deleted event with `trashed=with` and undo it with POST `events/{event}/actions/restore`. - user (Ref or UserSummary, required): The user whose agenda the event is on. Renders as an `{"id": N}` reference unless expanded. - links (EventLinks, required) - self (url, required): The canonical URI of this record. Only on `?view=detail`: description, selected_weekdays, recurrence_exceptions ## Variants ### EventList What a collection returns, and what `?view=summary` returns on a single record. 20 fields. Omits: description, selected_weekdays, recurrence_exceptions ### EventSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: user_id, task_id, parent_event_id, occurrence_date, end, description, is_all_day, is_recurring, recurrence_interval, recurrence_frequency, recurrence_pattern, recurrence_start, recurrence_end, selected_weekdays, recurrence_exceptions, created_at, updated_at, deleted_at, user, links ### EventLinks The `links` object on this resource. Keys: self ## Columns deliberately not published - client_id: The id plnnrly's agenda gives a dropped tile so a repeated create returns the stored event instead of a second one. Only the agenda uses it; an API client neither reads nor sends it. ## Example ```json { "id": 1, "user_id": 1, "task_id": 1, "parent_event_id": 1, "occurrence_date": "2026-03-04", "start": "2026-03-03T09:00:00Z", "end": "2026-03-03T10:30:00Z", "title": "Design review", "description": "

…

", "is_all_day": false, "is_recurring": false, "recurrence_interval": 1, "recurrence_frequency": "day", "recurrence_pattern": "day_of_month", "recurrence_start": "2026-03-04T09:15:00Z", "recurrence_end": "2026-03-04T09:15:00Z", "selected_weekdays": [ "monday" ], "recurrence_exceptions": [ "2026-03-04" ], "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "user": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # FreeDay A free day: one calendar date on which the organisation is closed, either instantiated from a recurring holiday or added by hand. Free days are excluded from the working days a furlough request consumes. The label is a copy taken at creation time and drifts independently of the holiday it came from. ## Used by - GET /api/v2/free-days: List free days (FreeDayList) - POST /api/v2/free-days: Create a free day (FreeDay) - GET /api/v2/free-days/{free_day}: Read a free day (FreeDay) - PATCH /api/v2/free-days/{free_day}: Update a free day (FreeDay) ## FreeDay fields - id (integer, required): Unique identifier. - description (string ≤ 255, required): Label for the free day, e.g. `Christmas Day`. For a holiday-derived day this is a copy of the holiday name taken when the free day was created, so renaming the holiday does not rewrite it. - date (date (YYYY-MM-DD), required): Calendar date the organisation is closed. - holiday_id (integer or null, required): Recurring holiday this free day instantiates, or `null` for a day added by hand. The foreign key nulls on delete, so removing the holiday leaves the free day behind as a manual day still carrying the copied label. - holiday (Ref or HolidaySummary or null, required): The recurring holiday this free day instantiates; absent for a manually added day. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FreeDayLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### FreeDayList What a collection returns, and what `?view=summary` returns on a single record. 8 fields. Identical to the detail object above. ### FreeDaySummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: holiday_id, holiday, created_at, updated_at, links ### FreeDayLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "description": "Office closed for the annual outing", "date": "2026-07-03", "holiday_id": 1, "holiday": null, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # FurloughBalanceMutation A furlough balance mutation: one signed ledger entry against an employee's leave balance for a `year` and a leave `type`. A negative `minutes` consumes balance and is a leave request over the period from `start` to `end`; a positive `minutes` grants balance and is an adjustment a manager posts. `is_approved` is three-state and the `null` is semantic: `null` means pending approval, `true` approved, `false` denied. The balance counts the pending and the approved alike, never the denied, so a pending request already reduces what is left. ## Used by - GET /api/v2/furlough-balance-mutations: List furlough balance mutations (FurloughBalanceMutationList) - POST /api/v2/furlough-balance-mutations: Create a furlough balance mutation (FurloughBalanceMutation) - GET /api/v2/furlough-balance-mutations/{furlough_balance_mutation}: Read a furlough balance mutation (FurloughBalanceMutation) - PATCH /api/v2/furlough-balance-mutations/{furlough_balance_mutation}: Update a furlough balance mutation (FurloughBalanceMutation) ## FurloughBalanceMutation fields - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The employee this mutation belongs to. Always the authenticated user on create and not writable. - acceptor_id (integer or null, required, server-assigned): The user who approved this mutation, set by the server when the mutation resolves to approved. Null while it is pending, and left `null` by a denial. Resolve the name through `GET /api/v2/users/{user}`. - year (integer, required): The balance year this mutation counts against. It is not derived from `start`: every balance sum counts by this year, so a mutation whose period moves into another year keeps counting against the year sent here until `year` is sent too. - type (string, required): Which leave balance this mutation moves. Regular and special leave are separate balances and never offset one another. - minutes (integer, required): The signed balance delta in minutes. Negative consumes balance (a leave request); positive grants it (a manager's adjustment, which requires the `furlough::manage` permission to write). The API never computes this from the employee's working week or the organisation's free days: send the exact number of minutes you mean. - start (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Start of the period this mutation covers, in UTC. Equal to `end` on a manual balance adjustment, which covers no period at all. - end (timestamp (YYYY-MM-DDThh:mm:ssZ), required): End of the period this mutation covers, in UTC. Never before `start`, and equal to it on a manual balance adjustment. - description (string, required, multiline): The employee's or manager's own words for this mutation: the reason for the leave, or what the adjustment is for. The only field `?q=` searches. - is_approved (boolean or null, required): Approval state, in three values: `null` means pending approval, `true` approved, `false` denied. The balance counts pending and approved mutations alike and ignores denied ones. Writing this field at all requires the `furlough::manage` permission. - user (Ref or UserSummary, required): The employee this mutation belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (FurloughBalanceMutationLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### FurloughBalanceMutationList What a collection returns, and what `?view=summary` returns on a single record. 14 fields. Identical to the detail object above. ### FurloughBalanceMutationSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: user_id, acceptor_id, type, start, end, description, is_approved, user, created_at, updated_at, links ### FurloughBalanceMutationLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "user_id": 1, "acceptor_id": 1, "year": 2026, "type": "regular", "minutes": -960, "start": "2026-07-06T00:00:00Z", "end": "2026-07-07T23:59:59Z", "description": "Long weekend", "is_approved": false, "user": { "id": 1 }, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Holiday A holiday: a recurring named day off, such as Nieuwjaarsdag. A holiday carries no calendar date of its own: it is a template whose actual dates live on the free days generated from it, so the same holiday serves every year. ## Used by - GET /api/v2/holidays: List holidays (HolidayList) - POST /api/v2/holidays: Create a holiday (Holiday) - GET /api/v2/holidays/{holiday}: Read a holiday (Holiday) - PATCH /api/v2/holidays/{holiday}: Update a holiday (Holiday) ## Holiday fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Name of the holiday, e.g. Nieuwjaarsdag. - default_month (integer or null, required): Month the holiday normally falls in, 1-12. Null means the holiday moves from year to year (Easter, say), and the organisation fills the date in per year on the free day itself. - default_day (integer or null, required): Day of the month the holiday normally falls on, 1-31, paired with `default_month`. Null means the day is not fixed. - recurrence_interval (integer or null, required): How often the holiday repeats, in years: `null` is not recurring, 1 is yearly, and n repeats every n years. - sort_order (integer, required, server-assigned): Position in the organisation's holiday list, ascending. Set by dragging holidays into order in plnnrly; the API exposes no way to reorder holidays. - free_days_count (integer ≥ 0, required): Number of free days generated from this holiday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (HolidayLinks, required) - self (url, required): The canonical URI of this record. - free_days (url, required): Free days generated from this holiday. ## Variants ### HolidayList What a collection returns, and what `?view=summary` returns on a single record. 10 fields. Identical to the detail object above. ### HolidaySummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: default_month, default_day, recurrence_interval, sort_order, free_days_count, created_at, updated_at, links ### HolidayLinks The `links` object on this resource. Keys: self, free_days ## Example ```json { "id": 1, "name": "Nieuwjaarsdag", "default_month": 1, "default_day": 1, "recurrence_interval": 1, "sort_order": 1, "free_days_count": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1", "free_days": "https:///api/v2/work-items/1" } } ``` --- # Invoice A legacy invoice header: the description, date and hourly rate a project was billed at before sales invoices replaced it. It can be listed, read and created, but never edited or deleted through the API: both are always refused. Its rows are not exposed in any form, so there is no line-item total here. ## Used by - GET /api/v2/invoices: List invoices (InvoiceList) - POST /api/v2/invoices: Create an invoice (Invoice) - GET /api/v2/invoices/{invoice}: Read an invoice (Invoice) ## Invoice fields - id (integer, required): Unique identifier. - project_id (integer, required): Project this invoice was raised for. - project (Ref or ProjectSummary, required): The project this invoice was raised for. - user_id (integer or null, required, server-assigned): Employee the invoice is attributed to. Assigned from the authenticated user on create and never writable; `null` once that user is deleted. - user (Ref or UserSummary or null, required): The employee this invoice is attributed to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - description (string, required, multiline): What was invoiced, as free text. This is the field `?q=` searches. - date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - hourly_rate (decimal (2 dp) ≥ 0, required): Hourly rate this invoice was billed at, as a decimal string with two places. Never rounded or rescaled. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a legacy invoice has no `version` field. - links (InvoiceLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### InvoiceList What a collection returns, and what `?view=summary` returns on a single record. 11 fields. Identical to the detail object above. ### InvoiceSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: project_id, project, user_id, user, description, created_at, updated_at, links Narrows hourly_rate: decimal (2 dp) ≥ 0 -> decimal (2 dp) ### InvoiceLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "project_id": 1, "project": { "id": 1 }, "user_id": 1, "user": null, "description": "Sprint 14: portal rebuild", "date": "2026-03-31", "hourly_rate": "85.00", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Label A label: a per-project tag work items are marked with, carrying the colour and the optional icon the backlog chips and the work item detail view show beside its name. Labels belong to one project and are attached to work items from the work-item side. ## Used by - GET /api/v2/labels: List labels (LabelList) - POST /api/v2/labels: Create a label (Label) - GET /api/v2/labels/{label}: Read a label (Label) - PATCH /api/v2/labels/{label}: Update a label (Label) ## Label fields - id (integer, required): Unique identifier. - project_id (integer, required): The project this label belongs to. Follow `GET /api/v2/projects/{project}` for its details; the project is not expandable from here. - name (string ≤ 255, required): Label name as shown on the chip, e.g. `Blocked`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. Stored verbatim: a row written before the colour picker enforced the format reads back as it is stored. - icon (string or null ≤ 255, required): Icon name, e.g. `heroicon-o-tag`: not a URL and not markup. Null when the label has no icon. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the next position in that project's label order, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (LabelLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### LabelList What a collection returns, and what `?view=summary` returns on a single record. 9 fields. Identical to the detail object above. ### LabelSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: project_id, icon, order, created_at, updated_at, links ### LabelLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "project_id": 1, "name": "Blocked", "color": "#ef4444", "icon": "heroicon-o-tag", "order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Media A file attached to a record: the metadata of one upload (its name, type, size and the collection it belongs to) plus an on-demand signed download URL. Files are reached only inside their parent's collection, `work-items/{work_item}/attachments` or `drive-items/{drive_item}/files`. The API never streams bytes: uploading is a two-step flow, where you create an upload ticket on the parent, PUT the raw bytes to the ticket's own signed URL outside this API, then POST the ticket's id as `upload_id` to the collection. That way `/api/v2` still accepts exactly one request content type, `application/json`. ## Used by - GET /api/v2/drive-items/{drive_item}/files: List a drive item's files (MediaList) - POST /api/v2/drive-items/{drive_item}/files: Attach an uploaded file to a drive item (Media) - GET /api/v2/work-items/{work_item}/attachments: List a work item's attachments (MediaList) - POST /api/v2/work-items/{work_item}/attachments: Attach an uploaded file to a work item (Media) ## Media fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): File name as uploaded. - mime_type (string or null ≤ 255, required): IANA media type. - size (integer, required): Size in bytes. - disk (string ≤ 255, required): Name of the storage location the file is kept in. - collection (string ≤ 255, required): Media collection the file belongs to. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Upload timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - url (string or null, optional, on request): Signed download URL, requiring your bearer token. Request with `?include=url`. Null only where your organisation's file storage cannot sign URLs, in which case the response carries a `signed_url_unavailable` warning and downloads are not available through the API. - url_expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, optional, on request): When `url` stops working. Always `null` in v2.0: the signed route the URL points at does not expire. Declared so a future bounded-TTL download path is an additive change. ## Variants ### MediaList What a collection returns, and what `?view=summary` returns on a single record. 10 fields. Identical to the detail object above. ### MediaSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: size, disk, collection, created_at, updated_at, url, url_expires_at ## Columns deliberately not published - conversions_disk: Used only by plnnrly's file storage. - custom_properties: Used only by plnnrly's file storage. - file_name: Storage file name; use `name`. - generated_conversions: Used only by plnnrly's file storage. - manipulations: Used only by plnnrly's file storage. - model_id: The other half of that parent reference, withheld with it. - model_type: A file is only ever reached inside its parent's collection, so the parent is the path rather than a payload field. - order_column: The file's position as arranged in plnnrly, which the API does not use. - responsive_images: Used only by plnnrly's file storage. - uuid: A second identifier used only by plnnrly's file storage; `id` is the one to use. ## Example ```json { "id": 1, "name": "…", "mime_type": "…", "size": 1, "disk": "…", "collection": "…", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "url": "…", "url_expires_at": "2026-03-04T09:15:00Z" } ``` --- # Note A free-text note attached to a project, a customer or a work item: the running commentary plnnrly shows in a record's Notes sub-page. The body is rich text from the editor, sanitised on the way out. A note is read through the record it hangs off, and written under that record's own note permission: `projects::notes`, `customers::update` or `project_management::manage_work_items` by parent type. ## Used by - GET /api/v2/notes: List notes (NoteList) - POST /api/v2/notes: Create a note (Note) - GET /api/v2/notes/{note}: Read a note (Note) - PATCH /api/v2/notes/{note}: Update a note (Note) ## Note fields - id (integer, required): Unique identifier. - content (html, required): Note body as rich-text HTML, sanitised on read. - order_column (integer, required, server-assigned): Sort position within the parent record. Assigned by the server; reordering is not exposed in v2.0. Positions are not counted per parent, so the sequence is effectively creation order across the whole organisation. - user_id (integer or null, required, server-assigned): Author of the note. Always the authenticated user at create time; `null` where the author has since been deleted. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - notable_type (string, required): The kind of record `notable` points at; it says which branch of `notable` applies. - notable_id (integer, required): The id of the record `notable` points at. - notable (Ref or ProjectSummary or CustomerSummary or WorkItemSummary, required): The project, customer or work item this note is attached to. - user (Ref or UserSummary or null, required): Author of the note. - links (NoteLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### NoteList What a collection returns, and what `?view=summary` returns on a single record. 11 fields. Identical to the detail object above. ### NoteLinks The `links` object on this resource. Keys: self ## Polymorphic fields - notable: discriminated on `notable_type` ## Example ```json { "id": 1, "content": "

Kick-off moved to Monday; the client will confirm scope by Friday.

", "order_column": 1, "user_id": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "notable_type": "project", "notable_id": 1, "notable": { "id": 1 }, "user": null, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Project A project: the unit customers are billed against. Carries a workflow mode that decides whether work is planned in sprints or on a continuous board. ## Used by - GET /api/v2/projects: List projects (ProjectList) - POST /api/v2/projects: Create a project (Project) - GET /api/v2/projects/{project}: Read a project (Project) - PATCH /api/v2/projects/{project}: Update a project (Project) - POST /api/v2/projects/{project}/actions/restore: Restore a deleted project (Project) ## Project fields - id (integer, required): Unique identifier. - title (string ≤ 255, required): Project title as shown in the UI. - full_title (string, required): Application name plus title. Never carries the project number, even when the organisation shows numbers in plnnrly. - description (html or null, required, detail only): Free-text description. HTML from the rich editor, sanitised. - status (string, required): Lifecycle status. Setting it to invoiced finishes every task on every budget of the project. - workflow_mode (string or null, required): Whether work is planned in sprints or on a simple board. - start_date (date (YYYY-MM-DD) or null, required): First day of the project. - end_date (date (YYYY-MM-DD) or null, required): Last day, `null` if open-ended. - is_billable (boolean, required): Whether time on this project is billable. - is_continuous (boolean, required): Whether the project has no planned end. - last_activity (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Most recent write anywhere in the project. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - customer_id (integer, required): Owning customer. - sub_customer_id (integer or null, required): Optional sub-customer. - project_manager_id (integer or null, required): Responsible project manager. - account_manager_id (integer or null, required, server-assigned): Responsible account manager. Set to the creating user and not writable. - application_id (integer or null, required): Linked application. Writable only while the organisation has the Infrastructure feature: sending a non-`null` value without it is a `422` `prohibited`. Null clears the link regardless. - workflow_id (integer or null, required): Workflow whose statuses this project's work items move through. - web_url (string, required): Absolute URL that opens this project's work in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and to the project page when none of them applies. Read-only. - customer (Ref or CustomerSummary, required): Owning customer. - sub_customer (Ref or CustomerSummary or null, required): Optional sub-customer. - project_manager (Ref or UserSummary or null, required): Responsible project manager. - account_manager (Ref or UserSummary or null, required): Responsible account manager. - sprints_count (integer ≥ 0, required): Number of sprints. - budgets_count (integer ≥ 0, required): Number of budgets. - tags_count (integer ≥ 0, required): Number of tags. - tasks_count (integer ≥ 0, required): Number of kanban tasks on this project that the caller may see. - workflow_mode_options (array of string, required, detail only): Workflow modes this project may switch to. Empty until a workflow is linked. - links (ProjectLinks, required) - self (url, required): The canonical URI of this record. - tasks (url, required): Kanban tasks on this project. - budgets (url, required): Budgets on this project. - sprints (url, required): Sprints on this project. - tags (array of TagSummary ≤ 50, optional, only with include): Tags on this project. A project takes at most 50 tags, so the include lists them all. `tag_ids` replaces the whole set. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Tag title as shown in the UI. - logged_minutes (integer or null, optional, on request): Sum of the worklog minutes on this project that the caller may see. Worklogs are per-user unless the caller holds `times::update_others` or `times::delete_others`. Null when the caller lacks `hours::read` or a token covering `api:read:times`. Only on `?view=detail`: description, workflow_mode_options ## Variants ### ProjectList What a collection returns, and what `?view=summary` returns on a single record. 31 fields. Omits: description, workflow_mode_options ### ProjectSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: full_title, description, status, workflow_mode, start_date, end_date, is_billable, is_continuous, last_activity, created_at, updated_at, deleted_at, sub_customer_id, project_manager_id, account_manager_id, application_id, workflow_id, web_url, customer, sub_customer, project_manager, account_manager, tags, sprints_count, budgets_count, tags_count, tasks_count, logged_minutes, workflow_mode_options, links ### ProjectLinks The `links` object on this resource. Keys: self, tasks, budgets, sprints ## Example ```json { "id": 1, "title": "Portal rebuild", "full_title": "…", "description": "

…

", "status": "active", "workflow_mode": "sprints", "start_date": "2026-03-04", "end_date": "2026-03-04", "is_billable": true, "is_continuous": false, "last_activity": "2026-03-04T09:15:00Z", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "customer_id": 1, "sub_customer_id": 1, "project_manager_id": 1, "account_manager_id": 1, "application_id": 1, "workflow_id": 1, "web_url": "…", "customer": { "id": 1 }, "sub_customer": null, "project_manager": null, "account_manager": null, "tags": { "data": [ { "…": "2 fields of TagSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "sprints_count": 1, "budgets_count": 1, "tags_count": 1, "tasks_count": 1, "logged_minutes": 1, "workflow_mode_options": [ "sprints" ], "links": { "self": "https:///api/v2/work-items/1", "tasks": "https:///api/v2/work-items/1", "budgets": "https:///api/v2/work-items/1", "sprints": "https:///api/v2/work-items/1" } } ``` --- # ProjectLink A named external URL pinned to a project, such as a staging environment, a design file or a repository. Deleting one is permanent, and every write bumps the owning project's `updated_at` and lands in the project's activity timeline. ## Used by - GET /api/v2/project-links: List project links (ProjectLinkList) - POST /api/v2/project-links: Create a project link (ProjectLink) - GET /api/v2/project-links/{project_link}: Read a project link (ProjectLink) - PATCH /api/v2/project-links/{project_link}: Update a project link (ProjectLink) ## ProjectLink fields - id (integer, required): Unique identifier. - project_id (integer, required): Owning project. - project (Ref or ProjectSummary, required): The project this link belongs to. - name (string ≤ 255, required): Link name as shown in the UI, e.g. `Staging`. This is the default sort key. - url (string, required, multiline): The link target, e.g. `https://staging.example.test`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (ProjectLinkLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### ProjectLinkList What a collection returns, and what `?view=summary` returns on a single record. 8 fields. Identical to the detail object above. ### ProjectLinkSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: project_id, project, created_at, updated_at, links ### ProjectLinkLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "project_id": 1, "project": { "id": 1 }, "name": "Staging", "url": "https://staging.example.test", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # SalesInvoice A sales invoice header: what a customer is billed, when it is due, and how far it has got through the accounting sync. The money on it is the sum of its rows, which are reached through `sales-invoices/{sales_invoice}/rows`; the API never writes rows inline. Invoices that predate the current invoicing flow are marked `is_legacy` and are read-only. ## Used by - GET /api/v2/sales-invoices: List sales invoices (SalesInvoiceList) - POST /api/v2/sales-invoices: Create a sales invoice (SalesInvoice) - GET /api/v2/sales-invoices/{sales_invoice}: Read a sales invoice (SalesInvoice) - PATCH /api/v2/sales-invoices/{sales_invoice}: Update a sales invoice (SalesInvoice) - POST /api/v2/sales-invoices/{sales_invoice}/actions/restore: Restore a deleted sales invoice (SalesInvoice) ## SalesInvoice fields - id (integer, required): Unique identifier. - customer_id (integer, required): Customer this invoice is billed to. - customer (Ref or CustomerSummary, required): The customer this invoice is billed to. Expanding it with `?include=customer` additionally requires the `customers::read` permission and a token scope covering the customers prefix. - project_id (integer or null, required): Project this invoice was raised for, when it was raised from one. - customer_subscription_id (integer or null, required): Customer subscription this invoice was generated from, when it was generated from a renewal. - invoice_number (string ≤ 255, required, server-assigned): Invoice number, allocated by the server from the organisation's yearly sequence: B-26-00001 for an invoice raised in this flow, L-1234 for one converted from a legacy invoice. It also works in the path: `GET sales-invoices/B-26-00001` works. This is the field `?q=` searches. - reference (string or null ≤ 255, required): The customer's own reference, typically a purchase-order number, reproduced on the invoice. - description (string or null ≤ 255, required): One-line description of what was invoiced. - invoice_date (date (YYYY-MM-DD), required): Invoice date, a zone-free calendar date. Newest first is the default sort. - due_date (date (YYYY-MM-DD) or null, required): Date payment is due, a zone-free calendar date. Null when no term was applied. - status (string, required, server-assigned): Lifecycle status. Resolved by the server on create and never writable: a caller without `sales_invoices::finalize` creates a `pending_approval` invoice, and any update by such a caller returns the invoice to `pending_approval`. - should_email (boolean, required): Whether the invoice is emailed to the customer when it syncs. The email is sent through the accounting integration, so it is forced to `false` in an organisation with no integration configured. Writing it requires `sales_invoices::finalize`. - subtotal (decimal (2 dp), required, server-assigned): Sum of the row line totals excluding VAT, as a decimal string. Recomputed from the rows; never written directly. - vat_total (decimal (2 dp), required, server-assigned): Sum of the row VAT amounts, as a decimal string. Recomputed from the rows; never written directly. - total (decimal (2 dp), required, server-assigned): Subtotal plus VAT, as a decimal string. Recomputed from the rows; never written directly. - exact_invoice_id (string or null ≤ 255, required, server-assigned): The accounting system's own identifier for this invoice, written by the sync. Not a local id. - exact_journal (string or null ≤ 255, required, server-assigned): Sales journal the invoice is booked in, resolved from the organisation's accounting settings on create. Null in an organisation with no integration configured. - sync_status (string, required, server-assigned): How far the invoice has got through the accounting sync. Set on create: local in an organisation with no integration configured, pending otherwise. - sync_error (string or null, required, detail only, server-assigned, multiline): Why the last sync attempt failed, verbatim from the accounting system. Null while nothing has failed. - synced_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice last synced successfully. - sync_attempted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was last handed to the sync, successfully or not. - paid_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the invoice was settled. Set on create for a local invoice that is immediately definitive; otherwise written when payment is registered. - is_legacy (boolean, required, server-assigned): Whether this invoice was converted from a legacy invoice. Such an invoice is read-only: PATCH and DELETE answer `409` `state-conflict` with reason `locked_by_legacy`. - legacy_invoice_id (integer or null, required, server-assigned): The id of the legacy invoice this invoice was converted from, so it can be matched with `GET /api/v2/invoices`. The legacy invoice may since have been removed. - internal_notes (string or null, required, detail only, multiline): Notes for colleagues. Never rendered on the invoice the customer receives. - rows_count (integer ≥ 0, required): Number of line rows on this invoice. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a sales invoice has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceLinks, required) - self (url, required): The canonical URI of this record. - rows (url, required): Line rows on this invoice. Only on `?view=detail`: sync_error, internal_notes ## Variants ### SalesInvoiceList What a collection returns, and what `?view=summary` returns on a single record. 28 fields. Omits: sync_error, internal_notes ### SalesInvoiceSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: customer_id, customer, project_id, customer_subscription_id, reference, description, due_date, status, should_email, subtotal, vat_total, total, exact_invoice_id, exact_journal, sync_status, sync_error, synced_at, sync_attempted_at, paid_at, is_legacy, legacy_invoice_id, internal_notes, rows_count, created_at, updated_at, deleted_at, links ### SalesInvoiceLinks The `links` object on this resource. Keys: self, rows ## Example ```json { "id": 1, "customer_id": 1, "customer": { "id": 1 }, "project_id": 1, "customer_subscription_id": 1, "invoice_number": "…", "reference": "PO-4471", "description": "Development work, January 2026", "invoice_date": "2026-02-01", "due_date": "2026-03-03", "status": "draft", "should_email": false, "subtotal": "1250.00", "vat_total": "1250.00", "total": "1250.00", "exact_invoice_id": "…", "exact_journal": "…", "sync_status": "pending", "sync_error": "…", "synced_at": "2026-03-04T09:15:00Z", "sync_attempted_at": "2026-03-04T09:15:00Z", "paid_at": "2026-03-04T09:15:00Z", "is_legacy": false, "legacy_invoice_id": 1, "internal_notes": "…", "rows_count": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1", "rows": "https:///api/v2/work-items/1" } } ``` --- # SalesInvoiceRow One line on a sales invoice: a label, a quantity, a unit price and a VAT percentage. An invoice's subtotal, VAT total and total are computed from nothing but its rows, and a row that settles budget hours also records the minutes it covers. ## Used by - GET /api/v2/sales-invoice-rows/{sales_invoice_row}: Read an invoice line (SalesInvoiceRow) - PATCH /api/v2/sales-invoice-rows/{sales_invoice_row}: Update an invoice line (SalesInvoiceRow) - GET /api/v2/sales-invoices/{sales_invoice}/rows: List an invoice's lines (SalesInvoiceRowList) - POST /api/v2/sales-invoices/{sales_invoice}/rows: Add a line to an invoice (SalesInvoiceRow) ## SalesInvoiceRow fields - id (integer, required): Unique identifier. - sales_invoice_id (integer, required, server-assigned): The invoice this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another invoice. - sales_invoice (Ref or SalesInvoiceSummary, required): The invoice this line belongs to. - subscription_item_id (integer or null, required): Subscription line this invoice line was generated from, when the row came out of a subscription renewal rather than being typed by hand. - budget_id (integer or null, required): Project budget this invoice line settles, when the row was generated from budget hours. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Empty is accepted, but a row without it fails the whole invoice's Exact sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - name (string ≤ 255, required): The line label, as printed on the invoice. Exact Online prints this when description is empty. - description (string or null, required, multiline): Longer line text. Exact Online prints this in preference to name. - quantity (decimal (2 dp), required): Number of units billed. Fractional and signed: a budget row carries hours, and a credit line carries a negative quantity. - unit_price (decimal (2 dp), required): Price per unit, excluding VAT. This is what actually bills. - hourly_rate (decimal (2 dp) or null, required): Rate plnnrly recomputes `unit_price` from when the aggregated-price toggle is switched on a budget row. Stored exactly as sent and `null` when omitted; it feeds no server-side computation. - billable_minutes (integer, required): Minutes of budget time this line settles. Zero on a hand-typed row. - is_aggregated_price (boolean, required): True: one line of quantity 1 priced at hours times rate. False: quantity carries the hours and `unit_price` the hourly rate. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line: quantity times `unit_price` times `vat_rate`, rounded to two decimals. Recomputed by the server whenever one of those three changes. - total_amount (decimal (2 dp), required, server-assigned): Line total excluding VAT (quantity times `unit_price`), which is the convention the invoice form writes. Recomputed by the server whenever quantity or `unit_price` changes. - sort_order (integer, required, server-assigned): Position of the line within its invoice, and the default sort key. Assigned on create; there is no way to reorder lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SalesInvoiceRowLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### SalesInvoiceRowList What a collection returns, and what `?view=summary` returns on a single record. 24 fields. Identical to the detail object above. ### SalesInvoiceRowSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: sales_invoice_id, sales_invoice, subscription_item_id, budget_id, exact_item_id, exact_vat_code, exact_cost_center, exact_gl_account, description, quantity, unit_price, hourly_rate, billable_minutes, is_aggregated_price, vat_rate, vat_amount, sort_order, created_at, updated_at, deleted_at, links ### SalesInvoiceRowLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "sales_invoice_id": 1, "sales_invoice": { "id": 1 }, "subscription_item_id": 1, "budget_id": 1, "exact_item_id": "…", "exact_vat_code": "…", "exact_cost_center": "…", "exact_gl_account": "…", "name": "Sprint 42: development", "description": "Feature work on the customer portal.", "quantity": "12.50", "unit_price": "95.00", "hourly_rate": "1250.00", "billable_minutes": 1, "is_aggregated_price": false, "vat_rate": 1, "vat_amount": "1250.00", "total_amount": "1250.00", "sort_order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Sprint A sprint: a time-boxed slice of a project's backlog. A sprint has three states and they are derived, not stored (`completed_at` set means completed, otherwise `is_active` `true` means active, otherwise upcoming), so there is no `state` field to read and no `filter[state]` to send. Starting and completing a sprint are not field writes: they live at POST `sprints/{sprint}/actions/start` and .../actions/complete, because either can fail for a state reason rather than an invalid-value reason. ## Used by - GET /api/v2/sprints: List sprints (SprintList) - POST /api/v2/sprints: Create a sprint (Sprint) - GET /api/v2/sprints/{sprint}: Read a sprint (Sprint) - PATCH /api/v2/sprints/{sprint}: Update a sprint (Sprint) - POST /api/v2/sprints/{sprint}/actions/complete: Complete a sprint (Sprint) - POST /api/v2/sprints/{sprint}/actions/start: Start a sprint (Sprint) ## Sprint fields - id (integer, required): Unique identifier. - project_id (integer, required): The project this sprint belongs to. A sprint cannot be moved between projects, so this is accepted on create only. - name (string ≤ 255, required): Sprint name as shown in plnnrly, e.g. `Sprint 14`. - description (string or null, required, multiline): Free-text sprint goal. Plain text, not markup. - start_date (date (YYYY-MM-DD) or null, required): Planned first day of the sprint. Independent of `started_at`: planning a date does not start the sprint. - end_date (date (YYYY-MM-DD) or null, required): Planned last day of the sprint. Independent of `completed_at`. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually started. Written only by POST `sprints/{sprint}/actions/start`; `null` on an upcoming sprint. - completed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required, server-assigned): When the sprint was actually completed. Written only by POST `sprints/{sprint}/actions/complete`; `null` until then. Non-`null` is what makes the derived state `completed`. - is_active (boolean, required, server-assigned): Whether this is the project's running sprint. Set by actions/start and cleared by actions/complete, never by PATCH. At most one sprint per project is active at a time. - order (integer, required, server-assigned): Display position within the project. Assigned on create as the last position in that project's sprint order, changed only by reordering in plnnrly, and not writable. - work_items_count (integer ≥ 0, required): Number of work items currently on this sprint, resolved and unresolved alike. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project (Ref or ProjectSummary, required): The project this sprint belongs to. Expanding it additionally requires the projects prefix's read grant and a token scope covering it. - links (SprintLinks, required) - self (url, required): The canonical URI of this record. - work_items (url, required): Work items on this sprint. ## Variants ### SprintList What a collection returns, and what `?view=summary` returns on a single record. 15 fields. Identical to the detail object above. ### SprintSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: project_id, description, start_date, end_date, started_at, completed_at, order, work_items_count, created_at, updated_at, project, links ### SprintLinks The `links` object on this resource. Keys: self, work_items ## Example ```json { "id": 1, "project_id": 1, "name": "Sprint 14", "description": "Finish the invoicing export.", "start_date": "2026-03-02", "end_date": "2026-03-13", "started_at": "2026-03-04T09:15:00Z", "completed_at": "2026-03-04T09:15:00Z", "is_active": false, "order": 1, "work_items_count": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "project": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1", "work_items": "https:///api/v2/work-items/1" } } ``` --- # SubscriptionItem One line on a customer subscription: what is being sold, how many, at what purchase and sales price, and at what VAT percentage. These lines are what a renewal turns into sales-invoice rows, and a subscription with no lines cannot be invoiced. ## Used by - GET /api/v2/customer-subscriptions/{customer_subscription}/items: List a subscription's lines (SubscriptionItemList) - POST /api/v2/customer-subscriptions/{customer_subscription}/items: Add a line to a subscription (SubscriptionItem) - GET /api/v2/subscription-items/{subscription_item}: Read a subscription line (SubscriptionItem) - PATCH /api/v2/subscription-items/{subscription_item}: Update a subscription line (SubscriptionItem) ## SubscriptionItem fields - id (integer, required): Unique identifier. - customer_subscription_id (integer, required, server-assigned): The subscription this line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another subscription. - customer_subscription (Ref or CustomerSubscriptionSummary, required): The subscription this line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): What this line sells. Rendered as {"id": N} until named in `?include=subscribable`, which additionally requires the read permission and token scope of the `subscription_products` prefix. Lines that bill a hosting domain or a server carry a `subscribable_type` this API publishes no shape for, and keep the {"id": N} form however they are requested; identify those from `subscribable_type` and `subscribable_id` yourself. Null on both halves is a free-text line priced by hand. - description (string or null, required, multiline): The line text, as printed on the invoice a renewal generates. May contain placeholders such as {{$domain}}; the API publishes the raw stored text and those are resolved only at invoice time. - quantity (integer, required): Number of units billed per period. - purchase_price (decimal (2 dp) ≥ 0, required): Cost price per unit, for margin bookkeeping only. It feeds neither generated amount below and appears on no invoice. - sales_price (decimal (2 dp) ≥ 0, required): Sales price per unit, excluding VAT. This is what actually bills: a renewal copies it into the generated invoice row's unit price. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - vat_amount (decimal (2 dp), required, server-assigned): VAT on this line, computed by the server: quantity x `sales_price` x `vat_rate` / 100. Read-only. - total_amount (decimal (2 dp), required, server-assigned): Line total including VAT (quantity x `sales_price` plus the VAT above), computed by the server. Note this is the opposite convention from `SalesInvoiceRow.total_amount`, which excludes VAT: adding VAT to this value double-counts it. Read-only. - sort_order (integer, required, server-assigned): Position of the line within its subscription, and the default sort key. Assigned on create; there is no way to reorder lines through the API. Only the ordering is meaningful: values are not unique across subscriptions. - exact_item_id (string or null ≤ 255, required): Exact Online article identifier this line bills against. Optional: a renewal falls back to the subscription product's own Exact values and then to the organisation's Exact defaults, so leaving it empty does not break the sync. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code rather than by id. - exact_gl_account (string or null ≤ 255, required): Exact Online general-ledger revenue account for this line. - exact_cost_center (string or null ≤ 255, required): Exact Online cost-centre code. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. This is also the `ETag` revision: a subscription line has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionItemLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### SubscriptionItemList What a collection returns, and what `?view=summary` returns on a single record. 22 fields. Identical to the detail object above. ### SubscriptionItemSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: customer_subscription_id, customer_subscription, subscribable_type, subscribable_id, subscribable, quantity, purchase_price, sales_price, vat_rate, vat_amount, total_amount, sort_order, exact_item_id, exact_vat_code, exact_gl_account, exact_cost_center, created_at, updated_at, deleted_at, links ### SubscriptionItemLinks The `links` object on this resource. Keys: self ## Polymorphic fields - subscribable: discriminated on `subscribable_type` ## Columns deliberately not published - resource_id: The other half of that pair, withheld with it. - resource_type: The hosting site or server a line is sold for has no published shape in this API, so it is not exposed. ## Example ```json { "id": 1, "customer_subscription_id": 1, "customer_subscription": { "id": 1 }, "subscribable_type": "subscription_product", "subscribable_id": 1, "subscribable": null, "description": "Webhosting {{$domain}}", "quantity": 1, "purchase_price": "4.50", "sales_price": "12.50", "vat_rate": 1, "vat_amount": "1250.00", "total_amount": "1250.00", "sort_order": 1, "exact_item_id": "…", "exact_vat_code": "…", "exact_gl_account": "…", "exact_cost_center": "…", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # SubscriptionProduct A sellable subscription product: the organisation's hand-maintained catalogue of what a customer subscription or a subscription type can bill for. Subscription lines point at a product and inherit its price, interval and VAT rate. ## Used by - GET /api/v2/subscription-products: List subscription products (SubscriptionProductList) - POST /api/v2/subscription-products: Create a subscription product (SubscriptionProduct) - GET /api/v2/subscription-products/{subscription_product}: Read a subscription product (SubscriptionProduct) - PATCH /api/v2/subscription-products/{subscription_product}: Update a subscription product (SubscriptionProduct) - POST /api/v2/subscription-products/{subscription_product}/actions/restore: Restore a deleted subscription product (SubscriptionProduct) ## SubscriptionProduct fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Product name as shown in the UI and on the subscription line. - description (string or null, required, multiline): Invoice-line template for subscription lines built from this product. Supports the placeholder tokens {{$domain}}, {{$site}}, {{$server}}, {{$period}}, {{$product}} and {{$customer}}, which are filled in when the line is invoiced; the template itself is kept verbatim. - purchase_price (decimal (2 dp) or null, required): What the product costs us, as a decimal string. Null when unknown. - sales_price (decimal (2 dp) or null, required): What the customer is billed per interval, as a decimal string. Null when the line carries its own price. - price_interval (string, required): Interval the sales price is charged per. - is_hosting (boolean, required): Whether this is a hosting product: one sold against a concrete site or server, which is what lets a subscription line name the infrastructure it covers. - vat_rate (integer or null, required): VAT rate as a whole percentage: 21 means 21%, not 0.21. Null when the rate is decided at invoicing time. - exact_item_id (string or null ≤ 255, required): Exact Online article this product maps to, by Exact's own external identifier (`exact_items.exact_id`), not by a local id. - exact_vat_code (string or null ≤ 255, required): Exact Online VAT code, by code (`exact_vat_codes.code`), not by id. - exact_cost_center (string or null ≤ 255, required): Exact Online cost centre, by code (`exact_cost_centers.code`), not by id. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionProductLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### SubscriptionProductList What a collection returns, and what `?view=summary` returns on a single record. 15 fields. Identical to the detail object above. ### SubscriptionProductSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: description, purchase_price, sales_price, price_interval, is_hosting, vat_rate, exact_item_id, exact_vat_code, exact_cost_center, created_at, updated_at, deleted_at, links ### SubscriptionProductLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "name": "Managed hosting, small", "description": "Managed hosting for {{$site}} over {{$period}}.", "purchase_price": "1250.00", "sales_price": "49.50", "price_interval": "monthly", "is_hosting": true, "vat_rate": 21, "exact_item_id": "…", "exact_vat_code": "…", "exact_cost_center": "…", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # SubscriptionType A subscription type: the organisation's hand-maintained template a customer subscription is instanced from, carrying the default billing interval and renewal behaviour new subscriptions start with. Its template lines live on the items sub-resource, and the type's name is the name plnnrly shows for every subscription of that type. ## Used by - GET /api/v2/subscription-types: List subscription types (SubscriptionTypeList) - POST /api/v2/subscription-types: Create a subscription type (SubscriptionType) - GET /api/v2/subscription-types/{subscription_type}: Read a subscription type (SubscriptionType) - PATCH /api/v2/subscription-types/{subscription_type}: Update a subscription type (SubscriptionType) - POST /api/v2/subscription-types/{subscription_type}/actions/restore: Restore a deleted subscription type (SubscriptionType) ## SubscriptionType fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Subscription type name. It is also the name every customer subscription of this type is displayed under, so renaming a type renames what its existing subscriptions read as. Names are not unique. - description (string or null, required, multiline): What subscriptions of this type cover, as free text. - default_billing_interval (string, required): Interval a new subscription of this type is billed at by default. Omitting it on create yields yearly, the stored default, not the monthly that plnnrly's form pre-selects, which is a form convenience the API deliberately does not reproduce. - auto_renew (boolean, required): Whether subscriptions created from this type renew automatically by default. The value is stored exactly as sent; nothing forces it to `false`. - active (boolean, required): Whether the type is offered in the catalogue. It is a catalogue flag only: nothing in the application filters on it, so deactivating a type does not stop existing subscriptions of that type from being renewed or invoiced. - sort_order (integer or null, required, server-assigned): Position in the catalogue, assigned by the server on create as one past the highest existing position. It is the default sort key, and there is no way to reorder types through the API: dragging types into order in plnnrly has no API equivalent in v2.0. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. It also feeds the `ETag` revision, because a subscription type has no `version` field. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - items_count (integer ≥ 0, required): Number of template lines on this subscription type. - links (SubscriptionTypeLinks, required) - self (url, required): The canonical URI of this record. - items (url, required): Template lines on this subscription type. ## Variants ### SubscriptionTypeList What a collection returns, and what `?view=summary` returns on a single record. 12 fields. Identical to the detail object above. ### SubscriptionTypeSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: description, default_billing_interval, auto_renew, active, sort_order, created_at, updated_at, deleted_at, items_count, links ### SubscriptionTypeLinks The `links` object on this resource. Keys: self, items ## Example ```json { "id": 1, "name": "Hosting retainer", "description": "Managed hosting and monitoring, billed monthly.", "default_billing_interval": "monthly", "auto_renew": true, "active": true, "sort_order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "items_count": 1, "links": { "self": "https:///api/v2/work-items/1", "items": "https:///api/v2/work-items/1" } } ``` --- # SubscriptionTypeItem One template line on a subscription type: what is sold, how many, at what purchase and sales price, and at what VAT percentage. A subscription type is a blueprint rather than a live subscription, so these lines bill nothing themselves: they are copied, by value, onto a customer subscription's own lines the moment a type is picked for it. Editing a template line afterwards does not reach the subscriptions already created from it. ## Used by - GET /api/v2/subscription-type-items/{subscription_type_item}: Read a template line (SubscriptionTypeItem) - PATCH /api/v2/subscription-type-items/{subscription_type_item}: Update a template line (SubscriptionTypeItem) - GET /api/v2/subscription-types/{subscription_type}/items: List a subscription type's template lines (SubscriptionTypeItemList) - POST /api/v2/subscription-types/{subscription_type}/items: Add a template line to a subscription type (SubscriptionTypeItem) ## SubscriptionTypeItem fields - id (integer, required): Unique identifier. - subscription_type_id (integer, required, server-assigned): The subscription type this template line belongs to. Taken from the path on create and never writable afterwards: a line cannot be moved to another type. - subscription_type (Ref or SubscriptionTypeSummary, required): The subscription type this template line belongs to. - subscribable_type (string or null, required): The kind of record `subscribable` points at; it says which branch of `subscribable` applies. - subscribable_id (integer or null, required): The id of the record `subscribable` points at. - subscribable (Ref or SubscriptionProductSummary or null, required): The record this template line sells, normally a subscription product. Null on a line that sells nothing in particular, and a bare {"id": N} reference with `subscribable_type` of `domain` or `server` on a line pointed at a hosting record, which only plnnrly itself can do. Expanding it with `?include=subscribable` additionally requires `subscription_products` read permission and a token scope covering that prefix. - name (string or null ≤ 255, required): Optional label for the template line. plnnrly's form does not show it, so most existing lines carry `null`; it exists for programmatic naming and is what `?q=` searches. - description (string or null, required, multiline): Invoice-line text template, copied verbatim onto the customer subscription's line and resolved only when that line is invoiced. Placeholder tokens survive the copy unresolved: {{$domain}}, {{$period}}, {{$product}}, {{$customer}}, {{$site}}, {{$server}}, plus the Dutch aliases {{$domein}}, {{$periode}} and {{$klant}}. - quantity (integer, required): Number of units the template line carries. Whole units: this is an integer, not the fractional quantity an invoice row uses. - default_purchase_price (decimal (2 dp), required): Purchase price per unit, excluding VAT, copied onto a new customer subscription's line. - default_sales_price (decimal (2 dp), required): Sales price per unit, excluding VAT, copied onto a new customer subscription's line. This is what eventually bills. - vat_rate (integer, required): VAT percentage as a whole number: 21 means 21%. - sort_order (integer, required, server-assigned): Position of the line within its subscription type, and the default sort key. Assigned on create; there is no way to reorder template lines through the API. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while live. - links (SubscriptionTypeItemLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### SubscriptionTypeItemList What a collection returns, and what `?view=summary` returns on a single record. 17 fields. Identical to the detail object above. ### SubscriptionTypeItemSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: subscription_type_id, subscription_type, subscribable_type, subscribable_id, subscribable, description, quantity, default_purchase_price, default_sales_price, vat_rate, sort_order, created_at, updated_at, deleted_at, links ### SubscriptionTypeItemLinks The `links` object on this resource. Keys: self ## Polymorphic fields - subscribable: discriminated on `subscribable_type` ## Example ```json { "id": 1, "subscription_type_id": 1, "subscription_type": { "id": 1 }, "subscribable_type": "subscription_product", "subscribable_id": 1, "subscribable": null, "name": "Managed hosting", "description": "Hosting for {{$domain}} over {{$period}}.", "quantity": 1, "default_purchase_price": "12.50", "default_sales_price": "29.95", "vat_rate": 1, "sort_order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Subtask A checklist subtask on a work item: a title, an optional rich-text note and a ticked-off flag, held in the work item's own checklist order. Subtasks only mean anything under the work item they belong to, so they are listed and created through that work item and read, edited and deleted on their own flat path. There is no organisation-wide collection of subtasks and no `filter[work_item_id]`. ## Used by - GET /api/v2/subtasks/{subtask}: Read a subtask (Subtask) - PATCH /api/v2/subtasks/{subtask}: Update a subtask (Subtask) - GET /api/v2/work-items/{work_item}/subtasks: List a work item's subtasks (SubtaskList) - POST /api/v2/work-items/{work_item}/subtasks: Add a subtask to a work item (Subtask) ## Subtask fields - id (integer, required): Unique identifier. - work_item_id (integer, required, server-assigned): The work item whose checklist this subtask belongs to. Taken from the path on create and not writable. - title (string ≤ 255, required): Subtask title, as shown on the checklist row. This is the field `?q=` searches. - description (html or null, required, detail only): Optional note on the subtask. HTML from the rich editor, sanitised on read and on write. Null when the subtask is just a title. - is_resolved (boolean, required): Whether the subtask is ticked off. - order (integer, required, server-assigned): Position in the work item's checklist. Assigned on create as the next position in that checklist, changed only by dragging in plnnrly, and not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - work_item (Ref or WorkItemSummary, required): The work item this subtask hangs off. - links (SubtaskLinks, required) - self (url, required): The canonical URI of this record. Only on `?view=detail`: description ## Variants ### SubtaskList What a collection returns, and what `?view=summary` returns on a single record. 9 fields. Omits: description ### SubtaskSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: work_item_id, description, order, created_at, updated_at, work_item, links ### SubtaskLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "work_item_id": 1, "title": "Wire up the SSO callback", "description": "

…

", "is_resolved": false, "order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "work_item": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Task A kanban task: one card on an employee's board, filed under a budget of a project, carrying an estimate in minutes that time is logged against. Tasks are per-user, so a caller sees only their own unless they hold `kanban::manage_for_others`. ## Used by - GET /api/v2/projects/{project}/tasks: List a project's tasks (TaskList) - POST /api/v2/projects/{project}/tasks: Create a task on a project (Task) - GET /api/v2/tasks/{task}: Read a task (Task) - PATCH /api/v2/tasks/{task}: Update a task (Task) ## Task fields - id (integer, required): Unique identifier. - budget_id (integer, required): Budget the task is filed under. This is the task's only link to a project (a task has no `project_id` of its own), and it is settable at create time only; a task cannot be re-budgeted through the API. - user_id (integer or null, required, server-assigned): The employee the task belongs to. Always the authenticated user on create; not writable. Null on a task left unassigned in plnnrly. - team_id (integer or null, required, server-assigned): Team the task is filed under. Nothing on the API path assigns it, so a task created through the API always carries `null`; plnnrly itself is the only place a team is set. Not writable, and there is no `team` object beside it. - due_date (date (YYYY-MM-DD) or null, required): Day the task is due. Required when creating a task through the API, and clearable afterwards; tasks created in plnnrly may carry `null`. - minutes (integer, required): Estimated effort, in minutes. - description (string ≤ 255, required): Short description shown on the kanban card. This is the task's label and the only field `?q=` searches. - comment (string or null ≤ 255, required): Optional longer comment on the card, capped at 255 characters. - is_finished (boolean, required): Whether the task is done. Cross-synced with `status` on every write: setting `status` to finished sets this `true` and any other status sets it `false`, while setting this `false` on a finished task moves `status` back to active. `status` wins when both are sent. - is_billable (boolean, required): Whether time logged on this task is billable. Defaults to `true` when a task is created through the API, regardless of the budget's own setting. - status (string, required): Kanban column the card sits in. - order_column (integer, required, server-assigned): Position on the kanban board, lowest first. Appended to the end of the board on create and changed when someone drags the card in plnnrly. Not writable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - budget (Ref or BudgetSummary, required): The budget this task is filed under. - user (Ref or UserSummary or null, required): The employee the task belongs to. - links (TaskLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### TaskList What a collection returns, and what `?view=summary` returns on a single record. 17 fields. Identical to the detail object above. ### TaskSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: budget_id, user_id, team_id, due_date, minutes, comment, is_finished, is_billable, status, order_column, created_at, updated_at, budget, user, links ### TaskLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "budget_id": 1, "user_id": 1, "team_id": 1, "due_date": "2026-09-15", "minutes": 240, "description": "Wire up the SSO callback", "comment": "…", "is_finished": false, "is_billable": false, "status": "on-hold", "order_column": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "budget": { "id": 1 }, "user": null, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Time A worklog: minutes of work booked by one employee against one project on one calendar day, optionally attached to a task and a work item. Billability is inferred from the task, and overriding the inference needs a `billable_change_reason`. An entry that exceeds its budget, or whose billability was overridden, enters a reviewer's queue until a reviewer settles it; while it sits there, and forever after it has been invoiced, it is locked against changes. ## Used by - GET /api/v2/worklogs: List worklogs (TimeList) - POST /api/v2/worklogs: Log time (Time) - GET /api/v2/worklogs/{worklog}: Read a worklog (Time) - PATCH /api/v2/worklogs/{worklog}: Correct a worklog (Time) ## Time fields - id (integer, required): Unique identifier. - date (date (YYYY-MM-DD), required): The calendar day the work happened. - minutes (integer, required): Duration in minutes, between 1 and 1440. - description (string ≤ 255, required): What the employee worked on. - project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted. - project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested. - budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted. - budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix. - task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted. - task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection. - work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted. - work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded. - user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST. - user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`. - is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write. - exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue. - reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`. - billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled. - reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it. - invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`. - sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimeLinks, required) - self (url, required): The canonical URI of this record. Only on `?view=detail`: reason, billable_change_reason ## Variants ### TimeList What a collection returns, and what `?view=summary` returns on a single record. 23 fields. Omits: reason, billable_change_reason ### TimeSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: description, project_id, project, budget_id, budget, task_id, task, work_item_id, work_item, user_id, user, is_not_billable, is_overtime, exceeded_budget, reason, billable_change_reason, reviewed_by, invoice_id, sales_invoice_row_id, created_at, updated_at, links ### TimeLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "date": "2026-09-10", "minutes": 150, "description": "Reviewed the invoice export and fixed the rounding", "project_id": 12, "project": null, "budget_id": 1, "budget": null, "task_id": 88, "task": null, "work_item_id": 41, "work_item": null, "user_id": 1, "user": null, "is_not_billable": false, "is_overtime": false, "exceeded_budget": false, "reason": "…", "billable_change_reason": "…", "reviewed_by": 1, "invoice_id": 1, "sales_invoice_row_id": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Timer A running stopwatch: one row per employee per timed record, created when the timer starts and deleted when it is discarded or its minutes are booked. There is at most one timer per employee-and-target pair. `started_at` is the only state a timer carries: elapsed time is derived from it by the client, which needs no server clock. A timer on a record type this API does not publish (an Outlook event, which the agenda really does time) renders `timeable` as the bare `{"id": N}` reference form, with `timeable_type` intact and no expansion available. ## Used by - GET /api/v2/timers: List running timers (TimerList) - POST /api/v2/timers: Start a timer (Timer) - GET /api/v2/timers/{timer}: Read a timer (Timer) - PATCH /api/v2/timers/{timer}: Correct a timer (Timer) ## Timer fields - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): Employee this timer belongs to. Always the authenticated user; a timer cannot be started for someone else. - user (Ref or UserSummary, required): The employee this timer belongs to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - timeable_type (string, required): The kind of record `timeable` points at; it says which branch of `timeable` applies. - timeable_id (integer, required): The id of the record `timeable` points at. - timeable (Ref or WorkItemSummary or EventSummary, required): The record being timed. Expanding it with `?include=timeable` additionally requires the read permission and token scope of the target's own prefix, per branch: `project_management` for a work item, events for a planning event. - started_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the stopwatch was started. Elapsed time is this value against the client's own clock; the API publishes no elapsed counter. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (TimerLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### TimerList What a collection returns, and what `?view=summary` returns on a single record. 10 fields. Identical to the detail object above. ### TimerSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: user_id, user, timeable_type, timeable_id, timeable, created_at, updated_at, links ### TimerLinks The `links` object on this resource. Keys: self ## Polymorphic fields - timeable: discriminated on `timeable_type` ## Example ```json { "id": 1, "user_id": 1, "user": { "id": 1 }, "timeable_type": "work_item", "timeable_id": 41, "timeable": { "id": 1 }, "started_at": "2026-03-04T09:15:00Z", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Upload An upload ticket: the server's permission for you to place one file's bytes in storage, and the record of what arrived. Create one against the record you want to attach to, PUT the raw bytes to `upload_url`, then POST its `id` as `upload_id` to that record's file collection to turn the bytes into a file. Tickets are yours alone, are single-use, expire, and can be deleted while unregistered. ## Used by - POST /api/v2/drive-items/{drive_item}/uploads: Start a file upload on a drive item (Upload) - GET /api/v2/uploads/{upload}: Read an upload ticket (Upload) - POST /api/v2/work-items/{work_item}/uploads: Start a file upload on a work item (Upload) ## Upload fields - id (integer, required): Unique identifier. Send this as `upload_id` when you register the bytes. - work_item_id (integer or null, required): The work item this ticket attaches to, or `null` when it targets a drive item. - drive_item_id (integer or null, required): The drive item this ticket attaches to, or `null` when it targets a work item. - collection (string ≤ 255, required): The media collection the file will land in. Always `attachments`: it is chosen by the route, and no request field can change it. - file_name (string ≤ 255, required): The sanitized file name the file will carry. Sent when the ticket is created; control characters are stripped and `#`, `/`, `\` and spaces become hyphens, so what you read back can differ from what you sent. - byte_size (integer, required): The size you declared when creating the ticket. A declaration, not a bound: the server counts the bytes it actually receives. - checksum_sha256 (string or null, required): The optional SHA-256 you declared when creating the ticket. When present the server refuses bytes that do not match it. - received_byte_size (integer or null, required): The number of bytes that actually arrived, measured by the server. Null until the upload lands. - received_checksum_sha256 (string or null, required): The SHA-256 the server computed over the bytes it received. Null until the upload lands. - mime_type (string or null ≤ 255, required): The media type detected from the received bytes. Detected, never declared: the API accepts no content type when creating the ticket and ignores the `Content-Type` of the upload itself. - status (string, required): Where the ticket is in its lifecycle. - upload_url (string or null, required): Where to PUT the file. An opaque absolute URL: send the raw file as the body of a PUT whose only header is your Authorization bearer token. No `Content-Type` is read and the body is stored exactly as sent, so a form envelope would become part of the file. Present only while status is `pending`. - expires_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): The deadline for the PUT. After it the ticket is dead and a new one must be created. - register_by (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): The deadline for the registration, set when the bytes land and measured from that moment, so a slow upload does not eat into the time you have to register. Null until the upload lands. - uploaded_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes arrived. Null until they do. - committed_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): When the bytes became a file. Null until they do. - media_id (integer or null, required): The id of the file this ticket became. Null until it is registered, and set exactly once. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the ticket was created. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (UploadLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### UploadList What a collection returns, and what `?view=summary` returns on a single record. 20 fields. Identical to the detail object above. ### UploadLinks The `links` object on this resource. Keys: self ## Columns deliberately not published - created_by_id: A ticket is bound to its issuer and a caller only ever sees their own. - disk: Storage location chosen by plnnrly; a caller-chosen one could write outside your organisation. - model_id: The other half of that target reference, withheld with it. - model_type: Published as `work_item_id` or `drive_item_id`, the target the ticket was created on. - organization_id: The organisation is implied by the host you authenticated against. - permission_prefix: The permission family the ticket was created under; checked again on every step. - storage_key: Server-derived storage key; publishing it would let a caller reach the staged bytes directly. - storage_token: The unguessable segment of the storage key; it is never disclosed. - ulid: The secret part of `upload_url`; `id` is the ticket's public identifier. ## Example ```json { "id": 1, "work_item_id": 1, "drive_item_id": 1, "collection": "…", "file_name": "quarterly-report.pdf", "byte_size": 184320, "checksum_sha256": "…", "received_byte_size": 1, "received_checksum_sha256": "…", "mime_type": "…", "status": "pending", "upload_url": "…", "expires_at": "2026-03-04T09:15:00Z", "register_by": "2026-03-04T09:15:00Z", "uploaded_at": "2026-03-04T09:15:00Z", "committed_at": "2026-03-04T09:15:00Z", "media_id": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # User A user: one member of the organisation, as plnnrly's people list shows them. Read-only: the API creates, updates and deletes no user, because password setting belongs to the invitation and reset flows and because roles, permissions and teams are out of scope as resources and as writable relations. Service accounts are never returned: a system user is absent from the collection and `404` by id. A soft-deleted user stays readable with `trashed=with`, which is how a sync client learns an employee left. ## Used by - GET /api/v2/me: Read the token owner (User) - GET /api/v2/users: List users (UserList) - GET /api/v2/users/{user}: Read a user (User) ## User fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Full name as shown throughout plnnrly. Searchable with `?q=`. - email (string ≤ 255, required): Work email address, unique within the organisation. Searchable with `?q=`, which is the practical way to resolve a person to an id. - color (string ≤ 255, required): The user's identifying colour, as a CSS colour string. Used by plnnrly's calendars and avatars; render it or ignore it. - job_title (string ≤ 255, required): Job title, e.g. Project Manager. - phone_number (string or null ≤ 255, required): Phone number as entered, in no normalised format. - timezone (string or null ≤ 255, required): The user's own IANA timezone, e.g. Europe/Amsterdam. Null means the organisation's default applies. Every timestamp in this API is UTC regardless; this is the zone a client renders them in for this person. - date_employed (date (YYYY-MM-DD) or null, required): First day of employment, or `null` where it was never recorded. A calendar date with no clock, so it is never converted to UTC. - birthday (date (YYYY-MM-DD) or null, required): Date of birth, or `null` where it was never recorded. - profitability_target_percentage (integer or null, required): Share of available hours this person is expected to bill, 1 to 100, or `null` where no target is set. Read by the profitability report; a user without a target is still reported on, just not judged against one. - working_hours (array of object or null, required, detail only): The contracted working week, one entry per shift, in week order. A weekday may carry several entries (a split day is two ranges), and a weekday that is not worked simply has none. Null or empty means no schedule has been recorded. - day (string or null ≤ 9, optional): Weekday the shift falls on: one of monday, tuesday, wednesday, thursday, friday, saturday, sunday. - start_time (string or null ≤ 5, optional): Shift start as `HH:MM`. - end_time (string or null ≤ 5, optional): Shift end as `HH:MM`, always after the start. A shift never wraps past midnight: a night shift is two entries, 22:00-24:00 on one day and 00:00-06:00 on the next. - work_locations (object, required, detail only): Where the user usually works on each weekday, keyed monday through sunday. The stored pattern lists only the days away from the office, so a weekday it does not name reads as office here. A single day's override from the agenda is not reflected. - monday (string, required): Monday. - tuesday (string, required): Tuesday. - wednesday (string, required): Wednesday. - thursday (string, required): Thursday. - friday (string, required): Friday. - saturday (string, required): Saturday. - sunday (string, required): Sunday. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - deleted_at (timestamp (YYYY-MM-DDThh:mm:ssZ) or null, required): Soft-delete timestamp, `null` while the user is active. - links (UserLinks, required) - self (url, required): The canonical URI of this record. Only on `?view=detail`: working_hours, work_locations ## Variants ### UserList What a collection returns, and what `?view=summary` returns on a single record. 14 fields. Omits: working_hours, work_locations ### UserSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: email, color, job_title, phone_number, timezone, date_employed, birthday, profitability_target_percentage, working_hours, work_locations, created_at, updated_at, deleted_at, links ### UserLinks The `links` object on this resource. Keys: self ## Columns deliberately not published - infoscreen: The user's info screen choice in plnnrly; meaningless to an API client. - settings: Per-user display preferences of the plnnrly app, with no meaning for an API client. ## Example ```json { "id": 1, "name": "…", "email": "…", "color": "…", "job_title": "…", "phone_number": "…", "timezone": "…", "date_employed": "2026-03-04", "birthday": "2026-03-04", "profitability_target_percentage": 1, "working_hours": [ { "…": "3 optional fields left out" } ], "work_locations": { "monday": "office", "tuesday": "office", "wednesday": "office", "thursday": "office", "friday": "office", "saturday": "office", "sunday": "office" }, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "deleted_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # UserTodo A todo: one unchecked task-list line in a drive document that mentions you. This is the list behind the Todos dashboard in plnnrly. ⚠ The collection is a derived copy of document content, not user-authored data: every write to a drive item rebuilds the whole todo set for that item from its HTML, deleting and re-creating the todos, so a todo has no stable identity across an edit of its source document and anything written through this collection is discarded on the next such edit. Completing a todo for real means unchecking-to-checking the task item in the document. The collection is hard-scoped to the authenticated caller with no escape: there is no permission, filter value or include that reads another user's todos. ## Used by - GET /api/v2/todos: List your todos (UserTodoList) - POST /api/v2/todos: Create a todo (UserTodo) - GET /api/v2/todos/{todo}: Read a todo (UserTodo) - PATCH /api/v2/todos/{todo}: Update a todo (UserTodo) ## UserTodo fields - id (integer, required): Unique identifier. - user_id (integer, required, server-assigned): The user the todo belongs to. Always the authenticated caller: this collection has no way to read or write another user's todos. - drive_item_id (integer, required): The drive item whose document this todo was parsed out of. Writable on create only; a todo cannot be re-parented. No relation object is exposed for it; read the document with `GET /api/v2/drive-items/{drive_item}`, which applies the drive access rules this collection does not. Archiving a drive item leaves its todos behind, so this id can name an item you can no longer read. - todo_content (string, required, multiline): The todo line as plain text. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - user (Ref or UserSummary, required): The user the todo belongs to, always the authenticated caller. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix. - links (UserTodoLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### UserTodoList What a collection returns, and what `?view=summary` returns on a single record. 8 fields. Identical to the detail object above. ### UserTodoSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: user_id, drive_item_id, created_at, updated_at, user, links ### UserTodoLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "user_id": 1, "drive_item_id": 1, "todo_content": "Confirm the kick-off date with the client", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "user": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # WorkItem A work item: the unit of work a project's backlog and board are made of, addressed by numeric id or by its human-readable issue key (ACM-123) on the same path. It carries a title, a rich-text description, an estimate, a priority and a position in its project's workflow, and it is the parent of comments, checklist subtasks, attachments, links to other work items and its own child work items. Moving it to another workflow status is not a PATCH: the move is validated against the project workflow, so it lives at POST `work-items/{work_item}/actions/transition`. ## Used by - GET /api/v2/work-items: List work items (WorkItemList) - POST /api/v2/work-items: Create a work item (WorkItem) - GET /api/v2/work-items/{work_item}: Read a work item (WorkItem) - PATCH /api/v2/work-items/{work_item}: Update a work item (WorkItem) - POST /api/v2/work-items/{work_item}/actions/move: Move a work item to a position (WorkItem) - POST /api/v2/work-items/{work_item}/actions/transition: Transition a work item (WorkItem) ## WorkItem fields - id (integer, required): Unique identifier. - issue_key (string or null ≤ 255, required, server-assigned): Human-readable key, e.g. `ACM-123`, allocated by the server from the project's customer code and a per-customer sequence. It also works in the path: GET `work-items/ACM-123` works. This is one of the two fields `?q=` searches. - title (string ≤ 255, required): Work item title. The other field `?q=` searches. - description (html or null, required, detail only): Work item description. HTML from the rich editor, sanitised on read and on write. Mention spans are preserved verbatim, so pass them back unchanged. Detail view only. - priority (string or null, required): Relative priority. Defaults to normal on create; `null` only on a legacy item that was never prioritised. - due_date (date (YYYY-MM-DD) or null, required): Due date, a zone-free calendar date. Null when no date was set. - estimation (integer or null, required): Original estimate, in minutes. - remaining_estimate (integer or null, required): Remaining estimate, in minutes. Null where none was entered, in which case plnnrly derives one from the estimate and the logged time. - location_name (string or null ≤ 255, required, detail only): Name of the place the work happens, as picked from the address search in plnnrly. Null when no location was set. Send it together with `location_latitude` and `location_longitude`; `null` all three to clear the location. Detail view only. - location_address (string or null ≤ 255, required, detail only): Formatted address of the location, when the place has one. Detail view only. - location_latitude (decimal (2 dp) or null -90 to 90, required, detail only): Latitude of the location in decimal degrees, -90 to 90. Detail view only. - location_longitude (decimal (2 dp) or null -180 to 180, required, detail only): Longitude of the location in decimal degrees, -180 to 180. Detail view only. - order (integer, required, server-assigned): Position within the sprint or backlog, and the default sort. Not writable through PATCH: move an item with POST `work-items/{work_item}/actions/move` and `list: backlog`. - column_order (integer or null, required, server-assigned): Position within the board column. Not writable through PATCH: move a card with `POST work-items/{work_item}/actions/move` and `list: board`. - version (integer, required): Revision counter, bumped once per write. It is the `ETag`'s revision: send it back as `If-Match` to detect a concurrent edit, which answers `412`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - project_id (integer, required): Project this work item belongs to. It cannot change: the issue key was allocated from the project's customer code. - task_id (integer or null, required): Kanban task this work item is booked against, when it is booked against one. It must belong to the item's project. A sprint project books the running sprint's hours through it, so an item in that project's running sprint needs one; a simple-mode project does not. Setting it also makes the task's owner the assignee. Follow it to `/api/v2/tasks/{task}`. - workflow_status_id (integer, required): Current workflow status. Not writable through PATCH: moving between statuses is `POST work-items/{work_item}/actions/transition`, because the move is validated against the project workflow. - work_type_id (integer, required): Work type of this item. - sprint_id (integer or null, required): Sprint the item sits in. Null means it is in the backlog. - parent_id (integer or null, required): Parent work item, for a sub-issue. Null on a top-level item. - assignee_id (integer or null, required): User the work is assigned to. Null on an unassigned item. On a sprint project, setting it also moves the item to that user's only open task in the same budget, or in the project when the item has no task yet; with several or no candidates the task is left as it is. - reporter_id (integer, required, server-assigned): User who filed the work item. Always the authenticated user at create time; there is no way to file on somebody else's behalf. - web_url (string, required): Absolute URL that opens this work item in plnnrly, on the organisation's own hostname. It is a stable link, safe to store: it redirects to the first of the project's backlog, all-work list or board the signed-in user may open, and opens the item there; it lands on the project page when none of them applies. Read-only. - workflow_status (Ref or WorkflowStatusSummary, required): The workflow status this item currently sits in. Always present. - parent (Ref or WorkItemSummary or null, required): The parent work item, on a sub-issue. - assignee (Ref or UserSummary or null, required): The user the work is assigned to. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - reporter (Ref or UserSummary, required): The user who filed the work item. Expanding it additionally requires the users prefix's read grant and a token scope covering it. - work_type (Ref or WorkTypeSummary, required): The work type of this item. Expanding it additionally requires the `work_types` prefix's read grant and a token scope covering it. - sprint (Ref or SprintSummary or null, required): The sprint this item sits in, when it is not in the backlog. - comments_count (integer ≥ 0, required): Number of comments on this work item. - subtasks_count (integer ≥ 0, required): Number of checklist subtasks on this work item. - attachments_count (integer ≥ 0, required): Number of files attached to this work item. - child_work_items_count (integer ≥ 0, required): Number of child work items (sub-issues). - labels_count (integer ≥ 0, required): Number of labels on this work item. - drive_items_count (integer ≥ 0, required): Number of linked drive documents the caller may open. Archived documents are not counted. - links_count (integer, required): Number of work items linked to this one, in either direction. - links (WorkItemLinks, required) - self (url, required): The canonical URI of this record. - comments (url, required): Comments on this work item. - subtasks (url, required): Checklist subtasks on this work item. - links (url, required): Links between this work item and others, in either direction. - attachments (url, required): Files attached to this work item. - child_work_items (url, required): Child work items (sub-issues) of this work item. - labels (url, optional): Every label on this work item, paginated. Present only while the Sprints feature is enabled, as `GET /labels` is. - drive_items (url, required): Every drive document linked to this work item that you may open, paginated. - labels (array of LabelSummary ≤ 25, optional, only with include): Labels on this work item. `label_ids` takes at most 50 ids and replaces the whole set: a label you leave out is removed. Build it from `links.labels`, which lists every label while the Sprints feature is enabled, not from `include=labels`, which stops at 25. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - name (string ≤ 255, required): Label name as shown on the chip. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#ef4444`. - drive_items (array of DriveItemSummary ≤ 25, optional, only with include): Drive documents linked to this work item that you may open. `drive_item_ids` takes at most 25 ids and replaces the whole set: a document you leave out is unlinked, and so is every linked document you cannot open. Build it from `links.drive_items`, not from `include=drive_items`, which stops at 25. Expanding it additionally requires the `project_drive` prefix's access grant and a token scope covering it. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Item title. - owner_type (string, required): The kind of record `owner` points at; it says which branch of `owner` applies. - owner_id (integer, required): The id of the record `owner` points at. - owner (Ref, required): The project, customer, organisation or user whose drive this item lives in. - comments (array of CommentSummary ≤ 25, optional, only with include): Comments on this work item, newest first, capped at 25. Follow `links.comments` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - author_type (string, required): The kind of record `author` points at; it says which branch of `author` applies. - author_id (integer, required): The id of the record `author` points at. - author (Ref or UserSummary, required): Who wrote the comment. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): When the comment was posted. - subtasks (array of SubtaskSummary ≤ 25, optional, only with include): Checklist subtasks on this work item, capped at 25. Follow `links.subtasks` for the rest. Wrapped in `{ data, meta }`; `meta.truncated` says whether the cap was hit and `meta.count` is the full count. - id (integer, required): Unique identifier. - title (string ≤ 255, required): Subtask title. - is_resolved (boolean, required): Whether the subtask is ticked off. Only on `?view=detail`: description, location_name, location_address, location_latitude, location_longitude ## Variants ### WorkItemList What a collection returns, and what `?view=summary` returns on a single record. 39 fields. Omits: description, location_name, location_address, location_latitude, location_longitude ### WorkItemSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: description, priority, due_date, estimation, remaining_estimate, location_name, location_address, location_latitude, location_longitude, order, column_order, version, created_at, updated_at, project_id, task_id, workflow_status_id, work_type_id, sprint_id, parent_id, assignee_id, reporter_id, web_url, workflow_status, parent, assignee, reporter, work_type, sprint, labels, drive_items, comments, subtasks, comments_count, subtasks_count, attachments_count, child_work_items_count, labels_count, drive_items_count, links_count, links ### WorkItemLinks The `links` object on this resource. Keys: self, comments, subtasks, links, attachments, child_work_items, labels, drive_items ## Example ```json { "id": 1, "issue_key": "ACM-123", "title": "Portal login rejects SSO users", "description": "

…

", "priority": "high", "due_date": "2026-03-04", "estimation": 240, "remaining_estimate": 1, "location_name": "…", "location_address": "…", "location_latitude": "45.00", "location_longitude": "90.00", "order": 1, "column_order": 1, "version": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "project_id": 1, "task_id": 1, "workflow_status_id": 1, "work_type_id": 1, "sprint_id": 1, "parent_id": 1, "assignee_id": 1, "reporter_id": 1, "web_url": "…", "workflow_status": { "id": 1 }, "parent": null, "assignee": null, "reporter": { "id": 1 }, "work_type": { "id": 1 }, "sprint": null, "labels": { "data": [ { "…": "3 fields of LabelSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "drive_items": { "data": [ { "…": "5 fields of DriveItemSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "comments": { "data": [ { "…": "5 fields of CommentSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "subtasks": { "data": [ { "…": "3 fields of SubtaskSummary" } ], "meta": { "truncated": false, "…": "1 optional field left out" } }, "comments_count": 1, "subtasks_count": 1, "attachments_count": 1, "child_work_items_count": 1, "…": "4 more fields" } ``` --- # WorkItemLink A directed link between two work items (`blocks`, `duplicates`, `relates_to` and four more), the rows the linked items section in plnnrly is built from. A link is read relative to the work item you asked about: one row is "blocks ACM-9" from one end and "is blocked by ACM-4" from the other, which is what `direction` and `relationship` carry. There is no PATCH: the only mutable property of a link is which two items it joins, so re-linking is a DELETE plus a POST. Deleting either work item deletes the link with it. ## Used by - GET /api/v2/work-item-links/{work_item_link}: Read a work item link (WorkItemLink) - GET /api/v2/work-items/{work_item}/links: List a work item's links (WorkItemLinkList) - POST /api/v2/work-items/{work_item}/links: Link a work item to another (WorkItemLink) ## WorkItemLink fields - id (integer, required): Unique identifier. - source_work_item_id (integer, required): Work item the link points away from. On a link created through `POST work-items/{work_item}/links` this is always the work item named in the path. - target_work_item_id (integer, required): Work item the link points at. Always in the same project as the source. - link_type (string, required): The relationship, stored from the source item's point of view: `blocks`, `clones`, `duplicates`, `merged_into`, `causes`, `relates_to` or `split_to`. Only `relates_to` is symmetric. - created_by (integer or null, required, server-assigned): User who created the link, or `null` where that account has since been deleted. Resolve it with `GET /api/v2/users/{user}`; it is not includable. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - direction (string, required): Which way this link points relative to the work item you asked about: `forward` when that item is the source, `inverse` when it is the target. On the flat `GET work-item-links/{work_item_link}` there is no work item in the path, so the link is read on its own terms and this is always `forward`. - relationship (string, required): The relationship in words, from the point of view of the work item you asked about: `blocks` from the source end, `is blocked by` from the target end. Always in English: the API pins the locale. - source_work_item (Ref or WorkItemSummary, required): Work item the link points away from. - target_work_item (Ref or WorkItemSummary, required): Work item the link points at. - links (WorkItemLinkLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### WorkItemLinkList What a collection returns, and what `?view=summary` returns on a single record. 12 fields. Identical to the detail object above. ### WorkItemLinkSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: link_type, created_by, created_at, updated_at, direction, relationship, source_work_item, target_work_item, links ### WorkItemLinkLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "source_work_item_id": 1, "target_work_item_id": 1, "link_type": "blocks", "created_by": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "direction": "…", "relationship": "…", "source_work_item": { "id": 1 }, "target_work_item": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # WorkType A work-item type: the organisation-wide categories work items are filed under (bug, task, story, …), carrying the colour and icon the backlog, board and Gantt render beside them. A type still used by work items cannot be deleted. ## Used by - GET /api/v2/work-types: List work types (WorkTypeList) - POST /api/v2/work-types: Create a work type (WorkType) - GET /api/v2/work-types/{work_type}: Read a work type (WorkType) - PATCH /api/v2/work-types/{work_type}: Update a work type (WorkType) ## WorkType fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Work-type name as shown in the UI, e.g. `Bug`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - icon (string ≤ 255, required): Icon name, e.g. `heroicon-o-rectangle-stack`: not a URL and not markup. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created types are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - links (WorkTypeLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### WorkTypeList What a collection returns, and what `?view=summary` returns on a single record. 8 fields. Identical to the detail object above. ### WorkTypeSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 4 fields. Omits: order, created_at, updated_at, links ### WorkTypeLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "name": "Spike", "color": "#6b7280", "icon": "heroicon-o-rectangle-stack", "order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # Workflow A workflow: the named set of statuses a work item moves through, plus the transitions allowed between them. A project points at one workflow. Statuses and transitions are managed as their own collections: a workflow is created empty. ## Used by - GET /api/v2/workflows: List workflows (WorkflowList) - POST /api/v2/workflows: Create a workflow (Workflow) - GET /api/v2/workflows/{workflow}: Read a workflow (Workflow) - PATCH /api/v2/workflows/{workflow}: Update a workflow (Workflow) ## Workflow fields - id (integer, required): Unique identifier. - name (string ≤ 255, required): Workflow name as shown in the UI, e.g. `Standard`. - order (integer, required, server-assigned): Display position; set by dragging in plnnrly and not writable. API-created workflows are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - statuses_count (integer ≥ 0, required): Number of statuses in this workflow. - links (WorkflowLinks, required) - self (url, required): The canonical URI of this record. - statuses (url, required): Statuses in this workflow, in workflow order. ## Variants ### WorkflowList What a collection returns, and what `?view=summary` returns on a single record. 7 fields. Identical to the detail object above. ### WorkflowSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 2 fields. Omits: order, created_at, updated_at, statuses_count, links ### WorkflowLinks The `links` object on this resource. Keys: self, statuses ## Example ```json { "id": 1, "name": "Bug triage", "order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "statuses_count": 1, "links": { "self": "https:///api/v2/work-items/1", "statuses": "https:///api/v2/work-items/1" } } ``` --- # WorkflowStatus One status in a workflow: the column a work item sits in, with the colour the badge is drawn in and whether reaching it resolves the item. Statuses belong to exactly one workflow and cannot be moved between workflows. Which statuses a work item may move to is a separate collection: the workflow transitions. ## Used by - GET /api/v2/workflow-statuses: List workflow statuses (WorkflowStatusList) - POST /api/v2/workflow-statuses: Create a workflow status (WorkflowStatus) - GET /api/v2/workflow-statuses/{workflow_status}: Read a workflow status (WorkflowStatus) - PATCH /api/v2/workflow-statuses/{workflow_status}: Update a workflow status (WorkflowStatus) ## WorkflowStatus fields - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this status belongs to. A status cannot be moved to another workflow: its transitions and the work items sitting in it belong to this one. - name (string ≤ 255, required): Status name as shown on the board column and the status badge, e.g. `To do`. - color (string ≤ 255, required): Six-digit hex colour, e.g. `#6b7280`. A stored value that is not six-digit hex reads back as `#6b7280`, so writing this field back unchanged replaces the malformed original. - is_resolution (boolean, required): Whether a work item reaching this status counts as resolved. Drives the backlog's done state and what a completed sprint carries over. - order (integer, required, server-assigned): Position within the workflow, left to right on the board; set by dragging in plnnrly and not writable. API-created statuses are created at 0 and fall back to the id tiebreaker. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this status belongs to. - links (WorkflowStatusLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### WorkflowStatusList What a collection returns, and what `?view=summary` returns on a single record. 10 fields. Identical to the detail object above. ### WorkflowStatusSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 4 fields. Omits: workflow_id, order, created_at, updated_at, workflow, links ### WorkflowStatusLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "workflow_id": 4, "name": "Done", "color": "#22c55e", "is_resolution": true, "order": 1, "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "workflow": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ``` --- # WorkflowTransition One legal edge of a workflow: the move a work item may make from one status to another, with the label the transition button carries. Every status change is checked against these edges, so an edge that does not exist here cannot be walked by the API or in plnnrly. Edges are directed, and duplicates are legal. ## Used by - GET /api/v2/workflow-transitions: List workflow transitions (WorkflowTransitionList) - POST /api/v2/workflow-transitions: Create a workflow transition (WorkflowTransition) - GET /api/v2/workflow-transitions/{workflow_transition}: Read a workflow transition (WorkflowTransition) - PATCH /api/v2/workflow-transitions/{workflow_transition}: Update a workflow transition (WorkflowTransition) ## WorkflowTransition fields - id (integer, required): Unique identifier. - workflow_id (integer, required): Workflow this edge belongs to. Both statuses belong to the same workflow, and the edge cannot be moved to another one. - from_status_id (integer, required): Status a work item must currently be in for this edge to apply. - to_status_id (integer, required): Status the work item moves to. Always different from `from_status_id`. - name (string ≤ 255, required): Transition label as shown on the status button, e.g. `Start progress`. - created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp. - updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync. - workflow (Ref or WorkflowSummary, required): Workflow this edge belongs to. - from_status (Ref or WorkflowStatusSummary, required): Status the edge leads out of. - to_status (Ref or WorkflowStatusSummary, required): Status the edge leads into. - links (WorkflowTransitionLinks, required) - self (url, required): The canonical URI of this record. ## Variants ### WorkflowTransitionList What a collection returns, and what `?view=summary` returns on a single record. 11 fields. Identical to the detail object above. ### WorkflowTransitionSummary The embedded form: what you get when another resource carries this one and you did not `include` it. 3 fields. Omits: workflow_id, name, created_at, updated_at, workflow, from_status, to_status, links ### WorkflowTransitionLinks The `links` object on this resource. Keys: self ## Example ```json { "id": 1, "workflow_id": 4, "from_status_id": 11, "to_status_id": 12, "name": "Start progress", "created_at": "2026-03-04T09:15:00Z", "updated_at": "2026-03-04T09:15:00Z", "workflow": { "id": 1 }, "from_status": { "id": 1 }, "to_status": { "id": 1 }, "links": { "self": "https:///api/v2/work-items/1" } } ```