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.