---
title: "Merge Time Entries"
method: POST
path: "/v1/time-entries/merge"
tags: ["time-entries"]
---

# Merge Time Entries

`POST /v1/time-entries/merge`

## Request

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

Fold 1..N time entries into another one, discarding the sources.

Backs the desktop gesture of dropping one or more entries onto
another entry's row. Every source's time blocks move to the target
and each source is soft-deleted; the target keeps its own narrative,
matter/segment, activity code and billability, and its legal time is
re-derived or summed by the rule in ``resolve_merged_legal_time``.

**This is deliberately not a block move with a flag.** A block-level
gesture never deletes the source entry (server/AGENTS.md), so the
deletion belongs to an explicitly entry-level endpoint. Per-source
rejections come back as structured ``FailedEntryMerge`` items;
problems with the target reject the whole request, since every
source shares it. Every returned row is in cursor-sync wire shape
for direct local-mirror apply.

## Request body

- MergeTimeEntriesRequest: Request model for folding 1..N time entries into another one. Backs the desktop gesture of dropping one or more entries onto another entry's row. Every source's time blocks move to the target and each source is discarded; the target keeps its own narrative, matter/segment, activity code and billable flag — see ``docs/decisions/0008-a-merge-keeps-the-target-entry.md``. Nothing about the surviving entry's content is client-supplied. In particular the resulting legal time is the server's to decide (``resolve_merged_legal_time``), because the rule depends on whether each figure was typed by a person — state the client mirrors but does not own.
  - `source_time_entry_ids` integer[], required: Entries to fold into the target. Each is discarded once its blocks have moved.
  - `target_time_entry_id` integer, required: The surviving entry. Keeps its own narrative, matter, code and billability.

## Response `200`

Successful Response

- MergeTimeEntriesResponse: Partial-success envelope for a merge. Every row is in cursor-sync wire shape and carries its post-mutation ``version``, so the desktop client force-applies the whole response to its local SQLite mirror rather than waiting a round: the target row gains the minutes, the discarded rows arrive as tombstones and evict, and the moved blocks re-parent. A source in ``failed`` ships no row data — the server never touched it, so the client's local row is already correct. When ``failed`` accounts for every source, nothing was written at all and ``time_entry`` is the untouched target.
  - `time_entry` TimeEntrySyncDto, required: A time entry as seen by the cursor sync feed. ``matter_segment`` is a denormalized snapshot — see ``TimeBlockSyncDto`` for rename-staleness semantics. ``version`` is the trigger-bumped cursor counter; ``updated_at`` is informational.
    - `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
  - `merged_blocks` TimeBlockSyncDto[]: Every time block this merge moved, in its post-merge state — normally re-parented onto the target
    - `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
  - `discarded` TimeEntrySyncDto[]: The merged-away source entries, tombstoned (deleted_at populated)
    - `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
  - `failed` FailedEntryMerge[]: Per-source rejections with structured codes; these entries were not touched
    - `time_entry_id` integer, required: ID of the source entry that was not merged
    - `code` 'INVALID_CREDENTIALS' | 'REFRESH_TOKEN_REQUIRED' | 'INVALID_REFRESH_TOKEN' | 'REFRESH_TOKEN_REUSE_DETECTED' | 'USER_NOT_FOUND' | 'NOT_AUTHENTICATED' | 'MS365_CONFIG_NOT_FOUND' | 'MS365_INVALID_STATE' | 'MS365_TOKEN_EXCHANGE_FAILED' | 'MS365_INVALID_USER' | 'MS365_GRAPH_REQUEST_FAILED' | 'MS365_CREDENTIALS_REJECTED' | 'MS365_PERMISSION_DENIED' | 'WRONG_AUTH_METHOD' | 'CAPABILITY_REQUIRED' | 'LAST_ADMINISTRATOR' | 'TOO_MANY_REQUESTS' | 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CONFLICT' | 'ALREADY_EXISTS' | 'STALE_CURSOR' | 'INVALID_CURSOR' | 'DATA_WIPE_FORBIDDEN_ENV' | 'TIME_BLOCK_INVALID_TIME_RANGE' | 'TIME_BLOCK_INVALID_SEGMENT' | 'SEGMENT_NOT_ASSIGNABLE' | 'TIME_BLOCK_INVALID_ACTIVITY_CODE' | 'TIME_ENTRY_RELEASE_NOT_FOUND' | 'TIME_ENTRY_RELEASE_EMPTY' | 'TIME_ENTRY_RELEASE_NO_MATTER' | 'TIME_ENTRY_RELEASE_NO_ACTIVITY_CODE' | 'TIME_ENTRY_RELEASE_NARRATIVE_PENDING' | 'TIME_ENTRY_NO_BLOCKS' | 'TIME_ENTRY_MERGE_NOT_FOUND' | 'TIME_ENTRY_MERGE_RELEASED' | 'TIME_ENTRY_MERGE_DIFFERENT_DATE' | 'TIME_ENTRY_MERGE_SELF' | 'GENERATION_UNAVAILABLE' | 'GENERATION_AI_INVALID_RESPONSE' | 'GENERATION_AI_PROVIDER_ERROR' | 'GENERATION_INPUT_TOO_LARGE' | 'GENERATION_CONCURRENT' | 'GENERATION_IN_PROGRESS' | 'GENERATION_TIMEOUT_RECOVERED' | 'GENERATION_DISPATCH_FAILED' | 'GENERATION_DISPATCH_LOST' | 'GENERATION_FAILED' | 'SPAN_EVENT_DISPATCH_EXHAUSTED' | 'EXTRACTION_IMAGE_NOT_FOUND' | 'EXTRACTION_IMAGE_INVALID' | 'EXTRACTION_AI_INVALID_RESPONSE' | 'EXTRACTION_AI_PROVIDER_ERROR' | 'EXTRACTION_INPUT_TOO_LARGE' | 'REASSEMBLY_ENRICH_FAILED' | 'REASSEMBLY_REBUILD_FAILED' | 'REASSEMBLY_TIMEZONE_INVALID' | 'REASSEMBLY_FAILED' | 'SPAN_OWNERSHIP_MISMATCH' | 'SPAN_INGEST_FAILED' | 'CAPTURE_IMAGE_OWNERSHIP_MISMATCH' | 'DATABASE_UNAVAILABLE' | 'UNKNOWN_ERROR', required: Centralized error codes for the API.
    - `message` string, required: Default human-readable message paired with code

## 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)
