Deze referentie is alleen in het Engels. Naar het Nederlandse REST API-overzicht

Diese Referenz gibt es nur auf Englisch. Zur deutschen REST-API-Übersicht

Cette référence existe uniquement en anglais. Aller à la présentation de l'API REST en français

Esta referencia solo está en inglés. Ir a la introducción de la API REST en español

Move work items through the workflow

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.

View as Markdown

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.

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; install it with pip install httpx. Run the steps in order: later steps reuse values from earlier ones.

Replace anything in angle brackets. <your-url> 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.

  • 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.

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.

2. Set up your script

Keep the address and the token out of your code, in two environment variables. Every snippet below uses them.

export PLNNRLY_BASE_URL="https://<your-url>/api/v2"
export PLNNRLY_TOKEN="<token>"
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') }
}
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.

curl "$PLNNRLY_BASE_URL/work-items/ACM-12?meta=actions" \
  -i \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Accept: application/json"
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)
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"
JSON
{
  "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:

curl -G "$PLNNRLY_BASE_URL/workflow-transitions" \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  --data-urlencode "filter[from_status_id]=27" \
  --data-urlencode "include=to_status"
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')
}
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")
JSON
{
  "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 with filter[workflow_id].

5. Change the status

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
  }'
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)
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. 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, 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

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
  }'
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)
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 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. A move to where the item already is changes nothing, so a retry is safe.

7. Leave a comment

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": "<p>Deployed to staging, ready for review.</p>"
  }'
await api('POST', '/work-items/ACM-12/comments', {
  headers: { 'Idempotency-Key': `comment-ACM-12-review-v${transitioned.json.data.version}` },
  body: { body: '<p>Deployed to staging, ready for review.</p>' },
})
api(
    "POST",
    "/work-items/ACM-12/comments",
    headers={"Idempotency-Key": f"comment-ACM-12-review-v{transitioned['data']['version']}"},
    json={"body": "<p>Deployed to staging, ready for review.</p>"},
)

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 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.