# POST /api/v2/worklogs

Log time

- MCP id: worklogs.store
- Scope: api:write:times
- Permission: none, the record's own rules still apply
- Rate cost: 1 unit, write bucket. A write costs the same whatever `view` you ask for.
- Cache-Control: private, no-store

Books minutes against a project on one calendar day and returns the entry with a `Location` header.

`user_id` is not accepted: the hours belong to the authenticated user unless `on_behalf_of_user_id` names someone else, and logging time for another user additionally requires `times::update_others`, enforced by that field. Every check inside the write (whether that person may log time on the project, owns the task and may log time on the work item) is evaluated against the person the hours are for, not against you, so `is_overtime` is dropped when they lack `times::write_overtime` even if you hold it. One exception, which plnnrly's own form shares: the check that project, task and budget fit together weighs the task against you, so filing hours on somebody else's task needs `times::write_on_others_tasks` from the caller as well. A task or work item that does not belong to the submitted project is a `422` on its own field; a task or work item you may read but may not book against is a `403`. `budget_id` is derived from the task and never accepted. `is_not_billable` is inferred from the task when omitted; sending a value that differs from the inference requires `billable_change_reason`, answering 422 on `/billable_change_reason` without it. Sending a `reason` marks the entry as exceeding its budget whether or not `exceeded_budget` is sent alongside. Either an exceeded budget or a billability override puts the entry in a reviewer's queue, and while it sits there every write to it by anyone but its author or a reviewer answers `409`. `Idempotency-Key` is required here: a retried time entry is real, invoiced damage. The API is stricter than plnnrly here: a task is accepted only if you can also see it on the tasks collection, so a caller holding `times::write_on_others_tasks` but not `kanban::manage_for_others` gets a `422` `exists` for a task plnnrly would accept.

## Parameters
- view (string, optional): Which field set to render.
- X-Request-Id (header, optional): A correlation id of your own. It is recorded in our logs next to the server's request id but never returned: the response header always carries the server's id. A value that does not match the pattern is discarded silently, never rejected.
- Idempotency-Key (header, required): A caller-chosen key that makes this POST safe to retry. Required on this operation: omitting it is 400 idempotency-key-required.

## Request body
- project_id (integer → Project.id, required): Project the hours are booked against; `null` once that project is deleted.
- date (date (YYYY-MM-DD), required): The calendar day the work happened.
- minutes (integer 1 to 1440, required): Duration in minutes, between 1 and 1440.
- description (string ≤ 255, required): What the employee worked on.
- task_id (integer or null → Task.id, optional): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted.
- work_item_id (integer or null → WorkItem.id, optional): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted.
- is_overtime (boolean, optional): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write.
- is_not_billable (boolean, optional): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`.
- billable_change_reason (string or null ≤ 65535, optional): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled.
- exceeded_budget (boolean, optional): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue.
- reason (string or null ≤ 65535, optional): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`.
- on_behalf_of_user_id (integer → User.id, optional): Write-only, on create only: the employee to file the hours for. Omit it to file them for yourself. Naming anyone else requires the `times::update_others` permission, and every check inside the write (whether that person may log on the project and on that task, and whether overtime may be recorded) is then evaluated against them, not against you.

Unknown keys are refused with `422` validation-failed, naming the key.

## Response 201
- id (integer, required): Unique identifier.
- date (date (YYYY-MM-DD), required): The calendar day the work happened.
- minutes (integer, required): Duration in minutes, between 1 and 1440.
- description (string ≤ 255, required): What the employee worked on.
- project_id (integer or null, required): Project the hours are booked against; `null` once that project is deleted.
- project (Ref or ProjectSummary or null, required): The project the hours are booked against, always expanded. Its `customer_id` is the customer to fetch with `GET /api/v2/customers/{customer}`; the customer itself is not nested.
- budget_id (integer or null, required, server-assigned): Budget the hours are invoiced against. Derived from the task, never accepted on a write; `null` when no task is set or the budget is deleted.
- budget (Ref or BudgetSummary or null, required): The budget the hours are invoiced against. Expanding it with `?include=budget` additionally requires the `budgets::read` permission and a token scope covering the budgets prefix.
- task_id (integer or null, required): Task the hours are booked on; `null` when the entry is not attached to one, or once that task is deleted.
- task (Ref or TaskSummary or null, required): The task the hours are booked on. Expanding it with `?include=task` additionally requires the `kanban::manage_self` permission and a token scope covering the kanban prefix, and is limited to the tasks you can see on the tasks collection.
- work_item_id (integer or null, required): Work item the hours are attached to; `null` when the entry is not attached to one, or once that work item is deleted.
- work_item (Ref or WorkItemSummary or null, required): The work item the hours are attached to, always expanded.
- user_id (integer or null, required, server-assigned): Employee the hours belong to. Assigned on create (from the authenticated user, or from `on_behalf_of_user_id`) and never writable afterwards; reassigning a worklog is a DELETE plus a POST.
- user (Ref or UserSummary or null, required): The employee the hours belong to. Expanding it with `?include=user` additionally requires the `users::read` permission and a token scope covering the users prefix.
- is_not_billable (boolean, required): Whether these hours are excluded from invoicing. Inferred from the task's `is_billable` flag (`false` without a task) on create and again whenever `task_id` is sent. Sending a value that differs from the inferred or current one requires `billable_change_reason` and puts the entry in a reviewer's queue; without it the write is a `422` on `/billable_change_reason`.
- is_overtime (boolean, required): Whether the hours count as overtime. Sending it without the `times::write_overtime` permission leaves it `false` and adds an `input_ignored` warning rather than failing the write.
- exceeded_budget (boolean, required): Whether the entry was booked knowing it exceeds its budget. Set on create only, and `true` whenever a `reason` is sent; setting it puts the entry in a reviewer's queue.
- reason (string or null, required, detail only, multiline): Why the budget overrun was accepted. Sending it on create marks the entry as exceeding its budget. Once a reviewer has settled the entry it can no longer be changed: a PATCH that does answers `409` `reason_reviewed`.
- billable_change_reason (string or null, required, detail only, multiline): Why the inferred billability was overridden. Required whenever `is_not_billable` is sent with a value that differs from the inferred or current one, on create and on PATCH alike; the override puts the entry in a reviewer's queue, reopening one a reviewer had already settled.
- reviewed_by (integer or null, required, server-assigned): The reviewer who settled the entry, once one has. Set by the review queue in plnnrly; this API exposes no way to write it.
- invoice_id (integer or null, required, server-assigned): The invoice these hours were billed on, once they have been. While it is set, the entry is locked: a write answers `409` with `conflict.reason` `locked_by_invoice`.
- sales_invoice_row_id (integer or null, required, server-assigned): The sales-invoice line these hours were billed on, once they have been. Set by the invoicing flow.
- created_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Creation timestamp.
- updated_at (timestamp (YYYY-MM-DDThh:mm:ssZ), required): Last-write timestamp; use for incremental sync.
- links (TimeLinks, required)
  - self (url, required): The canonical URI of this record.

## Errors
- 403 forbidden-by-policy | insufficient-permission | insufficient-token-scope: Forbidden.
- 409 idempotency-key-in-flight | idempotency-key-reused: Conflict.
- 415 unsupported-media-type: Unsupported media type.
- 422 unprocessable-relationship | validation-failed: Unprocessable content.
- 400 idempotency-key-required | invalid-cursor | invalid-query-parameter | malformed-request | unsupported-parameter: Bad request.
- 401 token-expired | unauthenticated: Unauthenticated.
- 404 endpoint-not-found | resource-not-found: Not found.
- 406 not-acceptable: Not acceptable.
- 429 rate-limit-exceeded: Too many requests.
- 500 internal-error: Server error.
- 503 tenant-unavailable: Service unavailable.

## Example
```bash
curl -X POST \
  'https://<your-url>/api/v2/worklogs' \
  -H 'Authorization: Bearer <token>' \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: example-key-0001' \
  -d '{
    "project_id": 12,
    "work_item_id": 41,
    "task_id": 88,
    "date": "2026-09-10",
    "minutes": 150,
    "description": "Reviewed the invoice export and fixed the rounding"
  }'
```
