# 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. `<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](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://<your-url>/api/v2"
export PLNNRLY_TOKEN="<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.
