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

Log time from a script

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.

View as Markdown

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.

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

ResourceWhy
Time entriesWorklogs.
ProjectsFinding the project, and the project_id on every worklog.
Project workspaceOnly if you attach work items, or time them.
Kanban boardsOnly if you attach tasks.
Worklog overviewOnly for timers. Despite the name, it does not cover worklogs.
UsersOnly 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.

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

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. Find the project

curl -G "$PLNNRLY_BASE_URL/projects" \
  -H "Authorization: Bearer $PLNNRLY_TOKEN" \
  -H "Accept: application/json" \
  --data-urlencode "filter[status]=active" \
  --data-urlencode "q=Website"
const { json: projects } = await api('GET', '/projects', {
  query: { 'filter[status]': 'active', q: 'Website' },
})

const project = projects.data[0]
console.log(project.id, project.title)
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.

JSON
{
  "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

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"
  }'
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}`)
}
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. 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"
JSON
{
  "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:

JSON
{
  "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 with code exists on /project_id.

5. Check the week

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"
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`)
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.

Without the hours::read permission, this answers 403 insufficient-permission, even though the entries are yours.

6. Correct an entry

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
  }'
const { json: corrected } = await api('PATCH', `/worklogs/${logged.data.id}`, {
  headers: { 'If-Match': etag },
  body: { minutes: 105 },
})

console.log(corrected.data.minutes)
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 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.

Two things cannot be corrected:

  • An invoiced entry. It answers 409 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.

# 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"
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}`)
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

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

  • It needs the times::update_others permission. Without it the answer is 403 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 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. A new entry needs a new key.
  • First attempt still running: 409 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 for the full rules, and Errors and retries for which failures are worth a retry.