# Uku API v3 — Full Reference > Complete API reference for AI agents and integrations. ## Authentication Headers required on every request: - `X-Uku-Company: ` — Company UUID (format: 8-4-4-4-12 hex) - `X-API-Key: ` — API key (format: uku_live_...) Scopes are granted per key as one of three tiers: - `read` — read-only (GET). - `write` — all writes EXCEPT financial fields. - `admin` — everything, including financial fields (invoice/contract money, product/tax pricing, IBAN/VAT, flextime, reclaims) and key/webhook management. `admin` ⊃ `write` ⊃ `read`. Financial mutations require `admin`. **Financial READS too** (2026-07-15): listing or fetching invoices (+rows), contracts (+rows), agreements, invoice-sellers, monitors, taxes, budgets, flextime, reclaims, and product prices requires the financials tier (`admin` today) — a `read`/`write`-only key gets 403 `FINANCIAL_SCOPE_REQUIRED` on those resources. --- ## Clients ### GET /api/v3/clients Query params: - `limit` (int, 1-200, default 50) - `offset` (int, 0-10000, default 0) - `sort` (string, e.g. "name", "-created_at") - `status` (string, comma-separated: "active,inactive,prospect") - `name` (string, exact match) - `reg_code` (string) - `ext_client_id` (string) - `q` (string, name search) - `default_person_id` (int) Response: ```json { "data": [{ "id": 1234, "name": "Acme Corp", "status": "active", "type": "client", "locale_code": "en_US", "reg_code": "12345678", "vat_code": "EE123456789", "address_street": "Main St 1", "address_city": "Tallinn", "address_state": null, "address_zip": "10111", "address_country_code": "ee", "default_person_id": 5678, "ext_client_id": "CRM-001", "client_initials": "AC", "parent_id": null, "has_monitor": 0, "timing_required": 1, "topic_required": 0, "custom_fields": {"industry": "Tech"}, "groups": [3, 5], "contacts": [{ "id": 300, "client_id": 1234, "person_id": null, "is_primary": 1, "name": "John", "surname": "Doe", "email": "john@acme.com", "job_title": "CEO", "mobile_phone": "+372 5551234", "work_phone": null, "locale_code": "en", "birthday": null, "ext_contact_id": null, "custom_fields": null, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" }], "contacts_truncated": false, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-03-01T14:20:00Z" }], "meta": {"total": 1, "limit": 50, "offset": 0, "has_more": false} } ``` ### POST /api/v3/clients Scope: `write` Body: ```json { "name": "Acme Corp", "status": "active", "reg_code": "12345678", "address_city": "Tallinn", "address_country_code": "ee" } ``` Required: `name` (1-255 chars). Optional: `status` (default "active"), `locale_code`, `reg_code`, `vat_code`, address fields, `default_person_id`, `ext_client_id`, `client_initials` (max 5), `parent_id`, `timing_required` (0/1), `topic_required` (0/1), `groups` (list of client-group IDs), `custom_fields` (object; alias `fields`). ### PATCH /api/v3/clients/{id} Scope: `write`. Only send fields you want to change. ### DELETE /api/v3/clients/{id} Scope: `write`. Returns 204. Soft-delete (recoverable). ### POST /api/v3/clients/{id}/documents-folder Scope: `write`. Creates (or returns the existing) client's cloud-drive folder tree and pins it. Requires a connected drive — `409 DRIVE_NOT_CONNECTED` otherwise; `400 FOLDER_PROVISION_FAILED` on a provider-side error. Response: `folder_id`, `folder_url`, `path`, `skipped` (true when the client already had a folder). --- ## Tasks ### GET /api/v3/tasks Query params: `limit`, `offset`, `sort`, plus: - `status` (comma-separated: "new,in_progress,finished,inactive,archived") - `type` ("task", "chat", "button", "request") - `client_id` (int) - `project_id` (int) - `topic_id` (int) - `assignee_id` (int, filter by assignee person ID) - `q` (string, title search) - `is_important` (bool) Response fields: ```json { "id": 100, "client_id": 1234, "project_id": null, "project_status_from": null, "topic_id": 5, "title": "Review contract", "description": "Check terms and conditions", "status": "in_progress", "type": "task", "is_important": false, "is_extra": false, "is_recurring": 0, "date": "2025-03-01T09:00:00Z", "due_date": "2025-03-15T17:00:00Z", "finished_at": null, "has_due_date": 1, "estimation": 120, "timezone": "Europe/Tallinn", "tracked_time": null, "assignees": [5678, 5679], "checklist": null, "custom_fields": null, "workflow_role_ids": [], "recurrence_rule": null, "comments": [], "comments_truncated": false, "created_at": "2025-03-01T09:00:00Z", "updated_at": "2025-03-10T11:30:00Z" } ``` ### POST /api/v3/tasks Scope: `write` Body: ```json { "title": "Review contract", "client_id": 1234, "status": "new", "type": "task", "assignees": [5678] } ``` Required: `title` (1-255 chars). Optional: `description`, `status` (default "new"), `type` (default "task"), `client_id`, `project_id`, `topic_id`, `is_important`, `is_extra`, `date`, `due_date`, `has_due_date`, `estimation`, `assignees` (a person-id list; also accepts the `assignee_ids` / `assignees_ids` aliases on input), `workflow_role_ids` (list of int), `custom_fields` (object; alias `fields`). ### PATCH /api/v3/tasks/{id} Scope: `write`. Partial update. `assignees` replaces the full list. ### DELETE /api/v3/tasks/{id} Scope: `write`. Returns 204. Soft-delete. --- ## Time Entries ### GET /api/v3/time-entries Query params: `limit`, `offset`, `sort`, plus: - `task_id` (int) - `person_id` (int) - `billable` (bool) - `start_from` (ISO 8601 datetime) - `start_to` (ISO 8601 datetime) Response fields: ```json { "id": 500, "task_id": 100, "task_name": "Review contract", "client_id": 1234, "topic_id": 5, "person_id": 5678, "start": "2025-03-10T09:00:00Z", "end": "2025-03-10T11:30:00Z", "duration": 9000, "billable": 1, "description": "Contract review", "created_at": "2025-03-10T09:00:00Z", "updated_at": "2025-03-10T11:30:00Z" } ``` ### POST /api/v3/time-entries Scope: `write`. Required: `task_id`, `person_id`, `start`. Optional: `end`, `duration` (seconds), `billable` (default true), `description`. A write that would leave the person a second open (`end: null`) entry returns `409 TIMER_ALREADY_RUNNING` — at most one running entry per person, same invariant the backoffice enforces. A write that overlaps one of the person's existing entries succeeds with a non-blocking `TIME_ENTRY_OVERLAP` entry in the response `warnings[]` array. ### PATCH /api/v3/time-entries/{id} Scope: `write`. Partial update. Same `409 TIMER_ALREADY_RUNNING` / `TIME_ENTRY_OVERLAP` semantics as POST. ### DELETE /api/v3/time-entries/{id} Scope: `write`. Returns 204. Soft-delete. --- ## Contacts ### GET /api/v3/contacts Query params: `limit`, `offset`, `sort`, plus: `client_id`, `email`, `q`. Response fields: ```json { "id": 300, "client_id": 1234, "person_id": null, "is_primary": 1, "name": "John", "surname": "Doe", "email": "john@acme.com", "job_title": "CEO", "mobile_phone": "+372 5551234", "work_phone": null, "locale_code": "en", "birthday": null, "ext_contact_id": null, "custom_fields": null, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T10:30:00Z" } ``` ### POST /api/v3/contacts Scope: `write`. Required: `client_id`, `name`. Optional: `surname`, `email`, `job_title`, `mobile_phone`, `work_phone`, `locale_code`, `is_primary`, `birthday`, `ext_contact_id`, `custom_fields` (alias `fields`). ### PATCH /api/v3/contacts/{id} Scope: `write`. Partial update — only send fields you want to change. ### DELETE /api/v3/contacts/{id} Scope: `write`. Returns 204. Soft-delete. --- ## Invoices Also supports `POST /api/v3/invoices` (create draft), `GET/PATCH /api/v3/invoices/{id}` (header fields), row CRUD (`GET/POST /api/v3/invoices/{id}/rows`, `GET/PATCH/DELETE /api/v3/invoices/{id}/rows/{row_id}`), `POST /api/v3/invoices/{id}/mark-paid`, and `POST /api/v3/invoices/{id}/send` — scope `admin`; all four writes also need `If-Match`. `mark-paid` and `send` wrap the same backoffice module/Celery flows (no reimplementation): `mark-paid` sets `paid_at`/`paid_sum` and requires status `created`/`sent` (409 `INVOICE_NOT_PAYABLE` otherwise); `send` queues the same Celery send task the app uses and returns 202 — status flips to `sent` asynchronously — and requires status `created`/`sent`/`paid` (409 `INVOICE_NOT_SENDABLE` otherwise). See `/api/v3/llms.txt` for write paths. ### GET /api/v3/invoices Query params: `limit`, `offset`, `sort`, plus: `status`, `client_id`, `invoice_date_from`, `invoice_date_to`. Response fields: `id`, `client_id`, `contract_id`, `status`, `invoice_number`, `invoice_date`, `due_date`, `net_sum`, `tax_sum`, `total_sum`, `paid_sum`, `currency`, `client_name`, `client_reg_code`, `client_vat_code`, `client_contact`, `seller_name`, `seller_reg_code`, `seller_vat_code`, `seller_address`, `seller_contact`, `locale_code`, `comments`, `reference_number`, `period_label`, `exported_at`, `sent_at`, `paid_at`, `rows`, `rows_truncated`, `created_at`, `updated_at`. `rows` inlines up to 200 `InvoiceRowOut` objects (`rows_truncated: true` if capped); each row has: `id`, `invoice_id`, `description`, `quantity`, `unit`, `net_price`, `net_sum`, `discount_rate`, `person_id`, `completed_at`, `tax_id`, `tax_rate`, `tax_name`, `tax_sum`, `product_id`, `article_id`, `resource_id`, `billing_project_id`, `ext_variable`, `original_client_id`. --- ## Members Also supports `PATCH /api/v3/members/{id}` (scope `write`; `flextime_credit`/`vacation_credit` need `admin` + `If-Match`). Writable fields: `name`, `surname`, `role` ("member"/"admin"/"owner"), `member_type`, `ext_user_id`, `hr_lead_id`, `locale_code`, `status` ("active"/"inactive"), plus the two financial credit fields above. ### GET /api/v3/members Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"inactive"/"pending"/"limited"), `role`, `q`. Response fields: `id`, `person_id`, `name`, `surname`, `email`, `phone`, `role`, `status`, `title`, `initials`, `locale_code`, `region_code`, `birthday`, `timezone`, `address_city`, `address_country_code`, `avatar`, `group_ids`, `ext_user_id`, `member_type`, `hr_lead_id`, `flextime_credit`, `vacation_credit`, `custom_fields`, `created_at`, `updated_at`. **(2026-07-15) `flextime_credit`/`vacation_credit` read as `null` unless the key has the `financials` scope** (`admin` implies it) — a mixed resource per Phase 6c: the rest of the member row stays open on plain `read`. Writable only with `admin` (unchanged). --- ## Products `POST /api/v3/products` (create) and `DELETE /api/v3/products/{id}` require `admin`. `PATCH /api/v3/products/{id}` uses `write` (money-bearing fields need `admin`). Per-product price CRUD (`GET/POST /api/v3/products/{id}/prices`, `GET/PATCH/DELETE /api/v3/products/{id}/prices/{price_id}`) needs `admin`. ### GET /api/v3/products Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"inactive"), `type`, `q`. Response fields: `id`, `name`, `status`, `type`, `type_args`, `unit`, `pricelist_type`, `bill_by`, `billable_filter`, `topics`, `translations`, `tax_id`, `tax_rate`, `tax_name`, `article_id`, `resource_id`, `billing_project_id`, `ext_variable`, `prices`, `created_at`, `updated_at`. (`prices` inlines the product's `ProductPriceOut` rows.) **(2026-07-15)** `unit`, `pricelist_type`, `bill_by`, `billable_filter`, `tax_id`, `tax_rate`, `tax_name`, `article_id`, `resource_id`, `billing_project_id` read as `null`, and `prices` reads as `[]`, unless the key has the `financials` scope (`admin` implies it) — a mixed resource per Phase 6c: the rest of the product row (`name`, `status`, `type`, `topics`, `translations`, ...) stays open on plain `read`. --- ## Topics Also supports `POST /api/v3/topics`, `PATCH/DELETE /api/v3/topics/{id}` (scope `write`). ### GET /api/v3/topics Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"inactive"), `q`. Response fields: `id`, `name`, `status`, `billable`, `color`, `created_at`, `updated_at`. --- ## Contracts `POST /api/v3/contracts` (create) and `DELETE /api/v3/contracts/{id}` require `admin` (`DELETE` also needs `If-Match`). `PATCH /api/v3/contracts/{id}` requires `write` + `If-Match`; money fields additionally need `admin`. Row writes (`GET/POST /api/v3/contracts/{id}/rows`, `GET/PATCH/DELETE /api/v3/contracts/{id}/rows/{row_id}`) need `admin` + `If-Match`. ### GET /api/v3/contracts Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"pending"/"finished"), `type` ("contract"/"template"), `client_id`, `q`. Response fields: `id`, `client_id`, `name`, `status`, `type`, `date_from`, `date_until`, `invoice_period`, `invoice_day`, `template_name`, `parent_id`, `settings`, `rows`, `rows_truncated`, `created_at`, `updated_at`. `settings` is a `ContractSettingsOut` object: `ext_user_id`, `currency`, `description`, `invoice_due_date_period`, `invoice_number_format`, `invoice_recipients`, `invoice_time_rounding`, `is_description`, `is_reverse_charge`, `is_summarized`, `locale_code`, `reference_number`, `reply_to`, `summarized_description`. `rows` inlines `ContractRowOut` objects: `id`, `contract_id`, `description`, `discount_rate`, `product_id`, `quantity`, `unit`, `invoice_time_min`, `net_price`, `tax_id`, `article_id`, `resource_id`, `billing_project_id`, `ext_variable`. --- ## Client Groups Also supports `POST /api/v3/client-groups`, `PATCH/DELETE /api/v3/client-groups/{id}` (scope `write`). ### GET /api/v3/client-groups Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"inactive"), `q`. Response fields: `id`, `name`, `color` (hex), `status`, `created_at`, `updated_at`. --- ## Invoice Sellers Also supports `PATCH /api/v3/invoice-sellers/{id}` (scope `write`; `name`, `address`, `company_number`, `vat_number`, `bank_1`, `bank_2`, `iban_1`, `iban_2` need `admin` — only `status`/`logo_url` are write-tier). ### GET /api/v3/invoice-sellers Query params: `limit`, `offset`, `sort`, plus: `status` ("active"/"archived"), `q`. Response fields: `id`, `name`, `status`, `address`, `company_number`, `vat_number`, `bank_1`, `iban_1`, `bank_2`, `iban_2`, `logo_url`, `created_at`, `updated_at`. --- ## Agreements `GET /api/v3/agreements`, `GET /api/v3/agreements/{id}` — company-wide agreements. `POST /api/v3/agreements`, `PATCH/DELETE /api/v3/agreements/{id}` — mutate (scope: admin; financial). `PATCH`/`DELETE` require `If-Match`; `POST` (create) does not. Create requires `date_from`; `cost_type` is "hour" (default) or "month". Per member: `GET/POST /api/v3/members/{id}/agreements`, `GET/PATCH/DELETE /api/v3/members/{id}/agreements/{agreement_id}`. Both list endpoints accept `date_from` / `date_until` filters. --- ## Preview resources (schema may change) Preview endpoints are always available; their responses include `X-Api-Preview: true`. Schemas may change across releases. Stable GA promotion is announced per-resource in the changelog. ### Projects `GET /api/v3/projects` (list filters: `status`, `client_id`, `manager_id`, `project_type_id`, `q`), `GET /api/v3/projects/{id}`. `POST/PATCH/DELETE /api/v3/projects[/{id}]` (scope: write). Create requires `project_type_id`. Status is changed via `status_action` on PATCH (`activate`/`cancel`/`archive`/`unarchive`), not by writing a raw status. Also carries `status_text` and `event_date`. ### Monitors `GET /api/v3/monitors`, `GET /api/v3/monitors/{id}` — list filters: `client_id`, `type`, `role_person_id`, `frequency`, `active`. `POST /api/v3/monitors` (scope: admin — `value`/`range_value` are money). `PATCH /api/v3/monitors/{id}` (scope: write; changing `value`/`range_value` needs admin + `If-Match`). `DELETE /api/v3/monitors/{id}` (scope: admin + `If-Match`). Nested: `GET/POST /api/v3/clients/{id}/monitors`. Overlap detection: a non-blocking `MONITOR_OVERLAP` entry in the response body `warnings[]` array (same channel as `AGREEMENT_OVERLAP`/`TIME_ENTRY_OVERLAP`; the legacy `X-Uku-Warning` header is still sent for one deprecation cycle); threshold writes need `admin`. ### Suppliers + supplier types `GET/POST/PATCH/DELETE /api/v3/suppliers[/{id}]` (scope: write). `GET/PATCH /api/v3/supplier-types[/{id}]` (scope: write). ### Taxes `GET /api/v3/taxes`, `GET /api/v3/taxes/{id}`. `PATCH /api/v3/taxes/{id}` (scope: write; `rate`/`percent` need admin). ### Notes `GET/POST/PATCH/DELETE /api/v3/notes[/{id}]` (scope: write). List filters: `related_type` (client/project/supplier/account_member), `related_id`, `type` (info/document/email/billing), `is_pinned`. ### Custom fields `GET /api/v3/custom-fields` (filters: `entity_type`, `status`, `is_system`, `q`), `GET /api/v3/custom-fields/{id}`. `POST/PATCH/DELETE /api/v3/custom-fields[/{id}]` (scope: write). System fields are read-only. `name`/`field_type`/`entity_type` are immutable post-create. Removing an option that's in use returns `409 CUSTOM_FIELD_OPTION_IN_USE`. ### Product fields Article/resource/cost-center/billing-project lookups. `GET/POST/PATCH/DELETE /api/v3/product-fields[/{id}]` (scope: write). ### Workflow roles Role slots in workflow templates. `GET/POST/PATCH/DELETE /api/v3/workflow-roles[/{id}]` (scope: write). Fields: `name`, `color`, `sort_order`. Offset-only; `limit` defaults to 100. DELETE is refused with `409 ROLE_IN_USE` while the role is still referenced by a client member or an active task. ### Roles Job titles / position names. `GET/POST/PATCH/DELETE /api/v3/roles[/{id}]` (scope: write). Field: `name`. Offset-only; `limit` defaults to 100. ### Content templates Email/document templates with per-locale rows. `GET/POST/PATCH/DELETE /api/v3/content-templates[/{id}]` (scope: write; list filters: `type`, `channel`, `q`). Rows: `POST /api/v3/content-templates/{id}/rows` (upsert per locale), `PATCH/DELETE /api/v3/content-templates/{id}/rows/{row_id}`. ### Budgets Project/client budgets with warning thresholds. `GET` (scope: financials), `POST` (scope: admin), `PATCH` (scope: write; `budget_net_sum`, `warning_rate`, `status` need admin), `DELETE` (scope: admin) — `/api/v3/budgets[/{id}]`. ### Task relations Task dependencies. `GET /api/v3/task-relations?task_id=` — list dependencies for a task. `POST /api/v3/task-relations` body `{task_id, depends_on_id}` (scope: write). `DELETE /api/v3/task-relations/{id}` — remove dependency. ### Flextime Member flextime balance. `GET` (scope: financials), `POST` (scope: admin), `PATCH` (scope: write; `duration` needs admin), `DELETE` (scope: admin) — `/api/v3/flextime[/{id}]`. ### Client members Team member assignments to clients. `GET/POST/PATCH/DELETE /api/v3/client-members[/{id}]` (scope: write; list filters: `client_id`, `person_id`, `role`). ### Attachments File uploads on tasks and notes. `GET /api/v3/attachments` — metadata list (filters: `related_type` ∈ task/note, `related_id`, `q`). `GET /api/v3/attachments/{id}` — metadata. `GET /api/v3/attachments/{id}/download` — streams file (fetched from cloud if needed; `409` while a fresh upload is still processing). `POST /api/v3/tasks/{id}/attachments` — multipart upload to a task. `POST /api/v3/notes/{id}/attachments` — multipart upload to a note (surfaces on the note's client/project Files page). Field name: `file`. Max 150MB → `413 FILE_TOO_LARGE`. MIME allowlist (images, PDF, Office, text, JSON, XML, ZIP, EML) → `415 UNSUPPORTED_MIME`. Extension allowlist + a magic-byte content check: a blocked/unlisted extension → `415 BLOCKED_EXTENSION`/`415 UNSUPPORTED_EXTENSION`; content that doesn't match the declared type → `415 MIME_MISMATCH`. `DELETE /api/v3/attachments/{id}` — soft delete (bytes retained for backup/restore). Scope: `write:attachments`. ### Calendar Vacation / time-off / flextime + global holiday reads. `GET /api/v3/calendar` — filter by `type`, `person_id`, `status`, `date_from`/`date_until`, `scope=company|global|all`. `GET /api/v3/calendar/{id}` — single entry. `POST /api/v3/calendar` — create vacation/flextime/sick_leave/time_off entry (scope: write). `PATCH /api/v3/calendar/{id}` — update (only writable types; holidays are read-only). `DELETE /api/v3/calendar/{id}` — soft delete. Approving (or un-approving / type-flipping) a credit-bearing `vacation`/`flextime` entry is backoffice-only → `409 CALENDAR_APPROVAL_UI_ONLY`. ### Task automations Scheduled email/notification rules per task/client/project. `GET/POST/PATCH/DELETE /api/v3/task-automations[/{id}]` (scope: write). ### Delegations Vacation task delegation to another member. `GET/POST/PATCH/DELETE /api/v3/delegations[/{id}]` (scope: write). ### Reclaims Billing reclaim audit + undo. `GET /api/v3/reclaims[/{id}]` — read-only listing of reclaimed work. `PATCH /api/v3/reclaims/{id}` — bare `undone_at` writes are rejected (409 `RECLAIM_UNDO_NOT_SUPPORTED`) — use the undo endpoint below. `POST /api/v3/reclaims/{id}/undo` (scope: `financials`) — reverses the reclaimed time entries/field quantities and marks the reclaim undone (409 `RECLAIM_NOT_UNDOABLE` if already undone / locked invoice / source task gone). --- ## API Key Management ### GET /api/v3/auth/keys Scope: `admin`. Personal keys are refused (403 `PERSONAL_KEY_CANNOT_MANAGE_KEYS`). Lists all API keys for this company (keys are masked, only prefix shown). Response: array of `{id, name, key_prefix, auth_type, kind, scopes, is_active, last_used_at, expires_at, ip_allowlist, created_at}`. ### POST /api/v3/auth/keys Scope: `admin`. Personal keys are refused (403 `PERSONAL_KEY_CANNOT_MANAGE_KEYS`). Creates a new API key. `kind` defaults to `"integration"` — company-wide, scope-only, exactly the existing behavior. `kind: "personal"` creates a key that acts AS one person (see "Personal keys" above): `scopes` must be omitted (always derived as `["read", "write"]` — a 400 `VALIDATION_ERROR` is returned if `scopes` is set at all with `kind: "personal"`); `person_id` defaults to the calling key's own person and must be an ACTIVE member of the company (400 `INVALID_PERSON` otherwise). Body (integration, unchanged): ```json { "name": "My Integration", "scopes": ["read", "write"], "expires_at": null, "ip_allowlist": null } ``` Body (personal): ```json { "name": "Claude Desktop", "kind": "personal", "person_id": null, "expires_at": null } ``` Response includes `full_key` — **save this immediately, it is shown only once** — plus `kind` and `person_id` (the acting identity for a personal key; the issuer for an integration key). ### POST /api/v3/auth/keys/{id}/rotate Scope: `admin`. Personal keys are refused (403 `PERSONAL_KEY_CANNOT_MANAGE_KEYS`). Rotates an API key — creates a new key, old key works for 24 hours. `kind` and the acting `person_id` are preserved from the old key (rotation never re-homes a personal key onto the rotating admin's own identity). Response: ```json { "new_key": {"id": 2, "name": "My Integration", "key_prefix": "uku_live_a1", "full_key": "uku_live_...", "kind": "integration", "person_id": 42, "scopes": ["read", "write"]}, "old_key_id": 1, "old_key_grace_expires_at": "2026-03-14T12:00:00Z" } ``` ### DELETE /api/v3/auth/keys/{id} Scope: `admin`. Personal keys are refused (403 `PERSONAL_KEY_CANNOT_MANAGE_KEYS`). Revokes an API key permanently. Returns 204. --- ## Webhooks **Coming soon — not available yet.** The `/api/v3/webhooks` endpoints are not part of this release and currently return `503 WEBHOOKS_COMING_SOON`. No events are delivered. Poll the resource endpoints instead; a signed, documented webhook feature will follow. --- ## Health ### GET /api/v3/health No auth required. Returns `200 {"status": "ok", "components": {"database": "up", "cache": "up"}}` when everything is up, or `503 {"status": "degraded", ...}` when a component is down. Component names are generic — no vendor or technology is exposed. Intended for load-balancer / uptime probes. --- ## Cursor Pagination The core resources support cursor-based pagination as an alternative to offset: clients, tasks, time-entries, contacts, invoices, members, products, topics, contracts, client-groups, invoice-sellers, projects, monitors, suppliers. Smaller preview/config resources (roles, workflow-roles, custom-fields, product-fields, taxes, notes, content-templates, calendar, attachments, agreements, webhooks, budgets, task-relations, flextime, client-members, task-automations, delegations, reclaims) are offset-only. Pass `?cursor=` from the previous response's `meta.next_cursor`. In cursor mode: - No `total` count (faster — skips COUNT query) - `meta.next_cursor` is set if there are more results - `meta.has_more` indicates if next page exists First request (no cursor): use `?limit=50` with optional `?sort=-created_at` Subsequent: use `?cursor=&limit=50` --- ## Rate Limiting Tiered per API key: - **Read tier** (GET): 120 requests/minute - **Write tier** (POST/PATCH/DELETE): 30 requests/minute Rate-limit headers (on authenticated, rate-limited responses): - `X-RateLimit-Limit` — max requests for this tier - `X-RateLimit-Remaining` — requests left in this window - `X-RateLimit-Reset` — Unix timestamp when window resets - `X-RateLimit-Tier` — "read" or "write" 429 response includes `Retry-After` (seconds until next window). --- ## Idempotency Supported on `POST /api/v3/invoices`, `/contracts`, `/agreements`, `/time-entries` only (exact match — sub-resource POSTs like `/invoices/{id}/rows` are not covered yet). Send `Idempotency-Key: ` (any string up to 200 chars) and a retried create with the same key + same API key replays the original stored response instead of creating a duplicate — response carries `Idempotency-Replayed: true` and the route body is not re-run. Only 2xx responses are stored (24h TTL); errors are never replayed, so a failed attempt can simply be retried with the same key. A second request arriving while the first is still in flight gets `409 IDEMPOTENCY_CONFLICT`. No header = no behavior change. --- ## Error Codes | HTTP | Code | Meaning | |------|------|---------| | 400 | MISSING_COMPANY | X-Uku-Company header not provided | | 400 | INVALID_COMPANY | UUID format invalid | | 400 | VALIDATION_ERROR | Request body validation failed | | 401 | MISSING_AUTH | X-API-Key header not provided | | 401 | UNAUTHORIZED | Invalid key or key/company mismatch | | 403 | FORBIDDEN | Key lacks required scope | | 404 | NOT_FOUND | Resource not found or not in your company | | 400 | INVALID_CURSOR | Invalid or expired pagination cursor | | 400 | INVALID_PARENT | Attachment parent (task) not found in your company | | 409 | ATTACHMENT_PROCESSING | File still being processed — retry the download shortly | | 413 | FILE_TOO_LARGE | Attachment exceeds the 150MB upload limit | | 415 | UNSUPPORTED_MIME | Attachment content-type not on the allowlist | | 415 | UNSUPPORTED_EXTENSION | Attachment file extension not on the allowlist | | 415 | BLOCKED_EXTENSION | Attachment file extension is blocked | | 415 | MIME_MISMATCH | Attachment bytes don't match the declared content-type | | 429 | RATE_LIMIT_EXCEEDED | Too many requests (120/min read, 30/min write) | | 403 | FINANCIAL_SCOPE_REQUIRED | Financial field write needs the `admin` scope | | 403 | SUBSCRIPTION_READ_ONLY | Tenant subscription is read-only; writes blocked | | 412 | STALE_WRITE | `If-Match` ETag is stale; re-GET and retry | | 428 | PRECONDITION_REQUIRED | `If-Match` header required for this financial write | | 400 | INVALID_ | A referenced id (e.g. `client_id`, `person_id`, `project_id`) is invalid or not in your company — e.g. `INVALID_CLIENT`, `INVALID_PERSON`, `INVALID_PROJECT` | | 400 | UNKNOWN_FILTER_FIELD | Field is not filterable on this resource | | 400 | UNKNOWN_OPERATOR | Unsupported filter operator | | 422 | INVALID_FILTER_VALUE | Filter value can't be parsed to the field's type | | 400 | UNSUPPORTED_TYPE | Unsupported value for this field | | 403 | READ_ONLY | Entity or field is read-only / system-managed (also `CUSTOM_FIELD_READ_ONLY`, `CUSTOM_FIELD_NOT_DELETABLE`, `PRODUCT_FIELD_READ_ONLY`) | | 403 | HTTPS_REQUIRED | Request must use HTTPS | | 409 | (resource-specific) | Conflict, e.g. `CLIENT_MEMBER_EXISTS`, `LAST_PRICE_REQUIRED`, `INVOICE_LOCKED`, `CONTRACT_LOCKED`, `MONITOR_EXISTS`, `CUSTOM_FIELD_EXISTS`, `INVALID_STATUS_TRANSITION`, `DRIVE_NOT_CONNECTED` | | 409 | TIMER_ALREADY_RUNNING | Time-entry write would leave the person a second open (`end: null`) entry | | 409 | CALENDAR_APPROVAL_UI_ONLY | Approving (or un-approving / type-flipping) a credit-bearing calendar entry (`vacation`/`flextime`) is a backoffice-only action | | 409 | ROLE_IN_USE | Workflow role still referenced by a client member or an active task — cannot delete | | 409 | PRODUCT_IN_USE | Product still referenced by a contract or invoice row — cannot delete | | 409 | GROUP_IN_USE | Client group still has active members — cannot delete | | 409 | INVOICE_NOT_SENDABLE / INVOICE_NOT_PAYABLE | Invoice status not eligible for `/send` or `/mark-paid` | | 409 | RECLAIM_UNDO_NOT_SUPPORTED / RECLAIM_NOT_UNDOABLE | Reclaim undo must use `POST /reclaims/{id}/undo`; undo refused if already undone, tasks gone, or period invoice-locked | | 409 | IDEMPOTENCY_CONFLICT / IDEMPOTENCY_KEY_REUSED | Same `Idempotency-Key` still in flight, or reused with a different request body | | 400 | FOLDER_PROVISION_FAILED | Client documents-folder provisioning failed at the cloud-drive provider | | 500 | INTERNAL_ERROR | Server error (details not exposed) | Responses may include a `"warnings"` array — each entry has `code` and `message` (e.g. deprecation or overlap notices).