# 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 <token>`
- 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 `/<key>`. 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/)
