Time Entries API
All examples use:
https://app.keito.ai/api/v2
Add Authorization: Bearer <kto_...> and Keito-Account-Id: <company_id> to every API key request.
Create a Time Entry
POST /api/v2/time_entries
Creates a time entry. To start a running timer, set is_running to true.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
project_id |
string | Yes | Project ID |
task_id |
string | Yes | Task ID |
spent_date |
string | Yes | Date of work (YYYY-MM-DD) |
hours |
number | No | Duration in decimal hours for completed entries |
is_running |
boolean | No | Create a running timer |
replace_running |
boolean | No | When true with is_running, stop any currently running timer before creating this one. When false or omitted, conflicting running-timer creates return 409 Conflict. |
started_time |
string | No | Start time (HH:mm) in the workspace timezone |
ended_time |
string | No | End time (HH:mm) in the workspace timezone for completed entries |
notes |
string | No | Description of work |
billable |
boolean | No | Override billable status |
source |
string | No | web, cli, api, agent, calendar, or desktop |
metadata |
object | No | JSON object, max 4KB |
external_reference |
object | No | Link to the item in another system that this time was logged against. See External References. |
started_time and ended_time are HH:mm workspace-time strings, not ISO timestamps. When is_running is true, omit started_time for a current server-side start. Past timer starts require the Past timer starts company setting; future start times are rejected.
Only one timer can be active for a user. By default, creating a running timer is strict: if another timer is already running, the API returns 409 Conflict with error: "running_timer_conflict" and the current running_entry. Pass replace_running: true to intentionally switch timers in one request.
Running Timer Conflict
{
"error": "running_timer_conflict",
"error_description": "A timer is already running.",
"message": "A timer is already running.",
"running_entry": {
"id": "running_time_entry_id"
},
"running_entry_count": 1
}
Example Request
curl -X POST https://app.keito.ai/api/v2/time_entries \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id" \
-H "Content-Type: application/json" \
-d '{
"project_id": "project_id_here",
"task_id": "task_id_here",
"spent_date": "2026-05-05",
"hours": 1.5,
"notes": "Refactored authentication module",
"source": "api",
"metadata": {
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}
}'
For a running timer, send is_running: true and omit hours. Send replace_running: true only when your integration should intentionally switch away from the currently running timer.
List Time Entries
GET /api/v2/time_entries
Returns a paginated list of time entries.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
page |
number | Page number |
per_page |
number | Results per page |
source |
string | Filter by source |
project_id |
string | Filter by project |
task_id |
string | Filter by task |
user_id |
string | Filter by user |
client_id |
string | Filter by client |
from |
string | Start date, inclusive |
to |
string | End date, inclusive |
is_billed |
boolean | Filter billed status |
invoice_id |
string | Filter entries linked to a Keito invoice ID; unavailable to personal read-only sync keys |
is_running |
boolean | Filter running timers |
approval_status |
string | Filter by unsubmitted, submitted, approved, or rejected |
external_reference_id |
string | Filter by external reference ID. Must be sent with external_reference_service. |
external_reference_service |
string | Filter by external reference service, such as tracker.example.com. Must be sent with external_reference_id. |
updated_since |
string | ISO timestamp lower bound |
Example Request
curl "https://app.keito.ai/api/v2/time_entries?source=cli&from=2026-05-01&to=2026-05-31" \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id"
Example Response Shape
{
"time_entries": [],
"per_page": 100,
"total_pages": 0,
"total_entries": 0,
"page": 1,
"links": {
"first": "/api/v2/time_entries?page=1&per_page=100",
"next": null,
"previous": null,
"last": "/api/v2/time_entries?page=1&per_page=100"
}
}
Approval Status
Every time entry includes approval_status: unsubmitted, submitted, approved, or rejected. Use it to tell whether time has been approved in Keito. Approved time also reports is_locked: true.
is_closed only reflects approvals imported from Harvest. Time approved in Keito reports approval_status: "approved" while is_closed stays false, so integrations should read approval_status. To approve time through the API, see the Timesheet Approvals API.
Invoice Linkage
Normal time-entry responses include the read-only invoice_id field:
| Field | Type | Description |
|---|---|---|
invoice_id |
string or null | The Keito invoice ID linked to the entry, or null when no Keito invoice link exists |
The link covers entries attached directly to an invoice line and entries included in a grouped invoice line. It is a Keito invoice ID, never a Harvest invoice ID or an invoice number. Use it with the Invoices API when your credential has permission to read that invoice.
is_billed: true does not guarantee an invoice link. Entries marked billed manually or imported as billed without a Keito invoice link return invoice_id: null. Do not infer an invoice from billed status.
To list an invoice’s linked entries, pass its ID:
curl "https://app.keito.ai/api/v2/time_entries?invoice_id=invoice_id_here" \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id"
The filter combines with existing filters and pagination and only narrows entries your credential can already read. Do not send invoice_id in POST or PATCH requests; invoice linkage is managed through the invoice workflow.
Personal read-only sync keys keep their reduced response schema: invoice_id is not included and the invoice_id filter is not supported.
Update a Time Entry
PATCH /api/v2/time_entries/:id
Updates editable fields on a time entry. source is immutable after creation.
curl -X PATCH https://app.keito.ai/api/v2/time_entries/time_entry_id \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id" \
-H "Content-Type: application/json" \
-d '{
"hours": 1.5,
"notes": "Completed OAuth implementation"
}'
External References
Link a time entry to the item it was logged against in another system by sending an external_reference object on create or update. Keito shows it as a linked chip on the entry. See External References for the fields, display, filters, and removal.
Stop a Running Timer
PATCH /api/v2/time_entries/:id/stop
Stops a running time entry and calculates elapsed duration server-side. Empty request bodies are accepted. If notes is supplied and the entry is editable, it replaces the entry notes. Do not send hours, ended_time, or is_running:false to stop a live timer; use this endpoint.
curl -X PATCH https://app.keito.ai/api/v2/time_entries/time_entry_id/stop \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id" \
-H "Content-Type: application/json" \
-d '{"notes": "Finished implementation"}'
Restart a Time Entry
PATCH /api/v2/time_entries/:id/restart
Restarts a stopped time entry as the active timer. Pass replace_running: true to intentionally stop any currently running timer first.
curl -X PATCH https://app.keito.ai/api/v2/time_entries/time_entry_id/restart \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id" \
-H "Content-Type: application/json" \
-d '{"replace_running": true}'
If another timer is running and replace_running is not supplied, the API returns 409 Conflict with running_entry.
Delete a Time Entry
DELETE /api/v2/time_entries/:id
Deletes a time entry. Approved or locked entries cannot be deleted.
curl -X DELETE https://app.keito.ai/api/v2/time_entries/time_entry_id \
-H "Authorization: Bearer kto_xxxxx" \
-H "Keito-Account-Id: your_company_id"
Returns HTTP 204 No Content on success.