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:
| 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.
Your role decides the rest:
- Finding projects, and every worklog, needs the
projects::readpermission, becauseproject_idis checked against it. Without it a worklog answers422with codeexistson/project_id, even for a project id you know. - Attaching or timing a work item needs
project_management::view_backlog. Attaching a task needskanban::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
403forbidden-by-policy. A task or work item from another project answers422on its own field. - Reading worklogs and timers back needs the
hours::readpermission, 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.
{
"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, asYYYY-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 is400idempotency-key-required. Build it from something that identifies the entry in your own system, such as the row4711in your timesheet, so a retry reuses it (step 9).
Optional fields:
work_item_idattaches the hours to a work item, andtask_idto a task. Leave both out to book on the project only.is_not_billableis best left out: billability follows the task. Overriding it needs abillable_change_reasonand 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/1.1 201 Created
Location: https://acme.plnnrly.com/api/v2/worklogs/5512
ETag: "worklogs-5512-t1791991800-9c1e"
{
"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:
{
"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
409state-conflict withconflict.reasonlocked_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_atdefaults to now, in UTC. You can run one timer per work item, so starting a second one answers422with codeunique, 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.
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_otherspermission. Without it the answer is403insufficient-permission, andrequired_permissionnames what is missing. - Naming a colleague also needs the
users::readpermission, and a narrowed token needs Users. Without them the answer is422validation-failed with codeexistson/on_behalf_of_user_id, beforetimes::update_othersis checked.GET /usersneeds the same two, and answers403without them. - The other rules are checked against your colleague. They need a task on the project, and
is_overtimeonly 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:
409idempotency-key-reused. A new entry needs a new key. - First attempt still running:
409idempotency-key-in-flight. Wait as long asRetry-Aftersays, 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.
Related
- Data formats: dates, times and minutes on the wire.
- Scopes and permissions: what your token and your role allow.
- Move work items through the workflow: the other recipe.