---
title: "Create Time Entry"
method: POST
path: "/v1/time-entries"
tags: ["time-entries"]
---

# Create Time Entry

`POST /v1/time-entries`

## Request

- Base URL: `https://app.tokumalabs.com/api`
- URL: `https://app.tokumalabs.com/api/v1/time-entries`
- Auth: OAuth 2
  - Password
  - Token URL: `v1/auth/token`

Create a new time entry, optionally sourced from existing time blocks.

Returns the row in the cursor-sync wire shape (``version``,
``deleted_at``, denormalized ``matter_segment``) so the desktop
client can apply the response directly to its local SQLite mirror
via the same idempotent upsert path used by ``GET /api/v1/sync``.

``time_block_ids`` attaches those blocks to the new entry in the same
transaction; the mutated blocks come back in ``attached_blocks`` so the
client's activity feed stops drawing them as pending without waiting for
the next sync round. The entry's own fields stay at the top level, so a
client that models only ``TimeEntrySyncDto`` reads this response exactly
as it read the old one.

## Request body

- CreateTimeEntryRequest: Request model for creating a new time entry.
  - `date` string, date, required: The date for this entry
  - `narrative` string, required: Description of the work performed
  - `legal_time_minutes` integer, required: Legal time in minutes
  - `activity_code` string, nullable: Activity code for the work
  - `segment_id` string, nullable: Segment ID to associate with this time entry
  - `is_billable` boolean: Whether the time entry is billable
  - `time_block_ids` integer[]: Unattached time blocks to attach to the new entry, in one transaction. Backs the desktop gesture that drops activity-feed blocks on the timesheet. A block that already belongs to a time entry is rejected: moving one between entries is what POST /v1/time-blocks/move is for, and it recalculates both sides. Empty (the default) creates an entry with no sources, which is what the plain Add-entry dialog does.
  - `legal_time_pinned` boolean: Whether ``legal_time_minutes`` is a figure the user typed. True stores it, snapped to the matter's billing increment. False means the client merely pre-filled the box from the attached blocks and the user did not touch it: the entry is left with a computed legal time and keeps following its blocks as they move. Defaults to true — the behaviour every caller before this field had.

## Response `201`

Successful Response

- CreateTimeEntryResponse: The created entry, plus any blocks the same request attached to it. **A subclass, not a widened ``TimeEntrySyncDto``.** ``TimeEntrySyncDto`` is the shared cursor-sync row shape; putting ``attached_blocks`` on it would ship a meaningless field on every row the feed serves and churn the golden wire fixtures. Subclassing keeps the extra field on this one endpoint. **Why the blocks come back at all.** The desktop client applies a mutation response straight into its local SQLite mirror. The attached blocks have a new ``time_entry_id`` and a bumped ``version``, and without them in the response the activity feed keeps drawing them as pending until the next sync round lands. **Backward compatible by construction.** The entry's own fields are unchanged and at the top level, so a client that models only ``TimeEntrySyncDto`` parses this response exactly as before — the Rust DTOs set no ``deny_unknown_fields`` and drop the extra key. That is what makes the server safe to deploy ahead of the app.
  - `id` integer, required
  - `organization_id` string, required
  - `user_id` string, required
  - `date` string, date, required
  - `legal_time_minutes` integer, required
  - `legal_time_is_computed` boolean, required
  - `tracked_duration_minutes` integer, required
  - `narrative` string, nullable, required
  - `narrative_suggestions` string[], nullable
  - `activity_code` string, nullable
  - `segment_id` string, nullable
  - `matter_segment` MatterSegmentSummaryDto: Summary of matter and segment for time blocks.
    - `matter_id` string, required: Matter ID
    - `matter_name` string, required: Matter name
    - `segment_id` string, required: Segment ID
    - `segment_name` string, required: Segment name
    - `segment_order` integer, required: Display order of the segment within the matter
    - `is_root` boolean, required: Whether this is the root segment
    - `root_segment_id` string, nullable: Root segment ID of the matter
  - `is_billable` boolean, required
  - `legal_time_pinned` boolean, required
  - `legal_time_resnapped_from` integer, nullable
  - `is_user_created` boolean, required
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required
  - `deleted_at` string, date-time, nullable
  - `version` integer, required
  - `attached_blocks` TimeBlockSyncDto[]: Time blocks attached to the new entry by this request, in cursor-sync wire shape. Empty when the request carried no ``time_block_ids``.
    - `id` integer, required
    - `organization_id` string, required
    - `user_id` string, required
    - `start_time` string, date-time, required
    - `end_time` string, date-time, required
    - `headline` string, required
    - `is_live` boolean, required
    - `app_id` string, nullable
    - `app_name` string, nullable
    - `segment_id` string, nullable
    - `matter_segment` MatterSegmentSummaryDto: Summary of matter and segment for time blocks.
      - `matter_id` string, required: Matter ID
      - `matter_name` string, required: Matter name
      - `segment_id` string, required: Segment ID
      - `segment_name` string, required: Segment name
      - `segment_order` integer, required: Display order of the segment within the matter
      - `is_root` boolean, required: Whether this is the root segment
      - `root_segment_id` string, nullable: Root segment ID of the matter
    - `activity_code` string, nullable
    - `time_entry_id` integer, nullable
    - `source_type` 'APP' | 'MS365_CALL' | 'MS365_EMAIL' | 'MS365_CALENDAR_EVENT', required: Source of a time block.
    - `headline_attempt_status` 'not_attempted' | 'succeeded' | 'skipped_no_signal' | 'failed_terminal': Vocabulary for `time_blocks.headline_attempt_status`. Stored as a `(str, Enum)` so the cursor-sync wire DTO can type the field as the enum and Pydantic v2 serializes to the bare value ('not_attempted' etc.) — the qualified-name footgun of plain `str(EnumMember)` is avoided.
    - `artifacts` BlockArtifactsDto: Artifacts for one block: a capped list plus the honest total. ``total`` is the pre-cap count of displayed (doc+email) artifacts — the same population as ``items`` — so a client showing the first N can render an honest "+more" without the wire ever implying the capped list is complete.
      - `items` ArtifactDto[]
        - `kind` 'doc' | 'email', required
        - `title` string, required
        - `location` string, nullable
      - `total` integer
    - `created_at` string, date-time, required
    - `updated_at` string, date-time, required
    - `deleted_at` string, date-time, nullable
    - `version` integer, required

## Other responses

- `400`: Validation error or domain rule violation.
- `401`: Missing, expired, or invalid authentication.
- `403`: Authenticated but not permitted.
- `404`: Resource not found.
- `409`: Resource state conflict (duplicate, illegal transition).
- `422`: Request body failed schema validation.
- `500`: Server-side failure.

---

[API](https://skmtc.dev/tokumalabs/apis/tokuma-user-api.md) - [All operations](https://skmtc.dev/tokumalabs/apis/tokuma-user-api/llms.txt) - [OpenAPI document](https://skmtc.dev/tokumalabs/apis/tokuma-user-api/revisions/1f8201af459b?raw)
