# 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, `<your-url>` 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://<your-url>/api/v2/work-items/ACM-12" \
  -H "Authorization: Bearer <token>" \
  -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://<your-url>/api/v2/work-items/ACM-12/actions/transition" \
  -H "Authorization: Bearer <token>" \
  -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://<your-url>/api/v2/work-items/ACM-12/actions/move" \
  -H "Authorization: Bearer <token>" \
  -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://<your-url>/api/v2/sprints/7/actions/complete" \
  -H "Authorization: Bearer <token>" \
  -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.
