# Karbon API — Agent Quick Reference

> **For agents and developers.** Concise patterns for making correct Karbon API calls.
> Latest version of [this guide](https://karbonhq.github.io/karbon-api-reference/KARBON_API.md)
> Full spec: [KarbonAPI.json](https://karbonhq.github.io/karbon-api-reference/KarbonAPI.json)

---

## 1. Overview

- **Base URL:** `https://api.karbonhq.com`
- **Version:** v3
- **Standard:** OData (v2 URI conventions)
- **Canonical source of available endpoints:** [`KarbonAPI.json`](https://karbonhq.github.io/karbon-api-reference/KarbonAPI.json) (this OpenAPI specification). `/v3/$metadata` is **not** a suitable source for discovering endpoints — see Tips for Agents below.

---

## 2. Authentication

Two headers required on every request:

```
Authorization: Bearer {token}
AccessKey: {key}
```

Both values are issued together from the same API Application in Karbon. Find them under **Settings → Connected Apps → API Applications → {Your API Application}**.

- **`Authorization` token** — GUID format: `550e8400-e29b-41d4-a716-446655440000`.
- **`AccessKey`** — JWT format: three Base64URL parts separated by dots, starts with `eyJ...`.

---

## 3. Common Patterns

### Pagination

All list endpoints support pagination. Max 100 items per request (for most endpoints).

| Parameter | Purpose                                      |
| --------- | -------------------------------------------- |
| `$top`    | Items per page (max: 100 for most endpoints) |
| `$skip`   | Items to skip                                |

Response fields:

| Field             | Description                                                                                           |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| `@odata.count`    | Total matching records (note that some endpoints require `$count=true` for this field to be included) |
| `@odata.nextLink` | URL for the next page — use this directly, don't increment `$skip` manually                           |

**Response envelope:**

```json
{
  "@odata.context": "...",
  "@odata.count": 323,
  "@odata.nextLink": "https://api.karbonhq.com/v3/Contacts?$skip=100",
  "value": [ ... ]
}
```

---

### OData Filtering (`$filter`)

Supported operators vary by endpoint and field — not all operators work with all fields.

| Operator                  | Meaning                       |
| ------------------------- | ----------------------------- |
| `eq`                      | Exact match                   |
| `contains(field, 'val')`  | Partial string match          |
| `and`                     | Combine conditions            |
| `ge` / `le` / `gt` / `lt` | ≥ / ≤ / > / < (dates/numbers) |
| `in`                      | Set membership                |

**Filterable fields by endpoint:**

| Endpoint                        | Filterable fields                                                                                                                                                 | Supported operators                 |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `GET /v3/Contacts`              | `FullName`, `EmailAddress`, `PhoneNumber`, `ContactType`, `ExternalKey`†                                                                                                          | `eq`, `contains`, `and`             |
| `GET /v3/Organizations`         | `FullName`, `EmailAddress`, `ContactType`, `ExternalKey`†                                                                                                                         | `eq`, `contains`, `and`             |
| `GET /v3/ClientGroups`          | `FullName`                                                                                                                                                        | `eq` only                           |
| `GET /v3/Teams`                 | `Name`                                                                                                                                                            | `eq` only                           |
| `GET /v3/Roles`                 | `Name`                                                                                                                                                            | `eq` only                           |
| `GET /v3/WorkItems`             | `AssigneeEmailAddress`, `ClientKey`†, `PrimaryStatus`†, `Title`, `WorkScheduleKey`†, `WorkStatus`, `WorkTemplateKey`†, `WorkType`                                 | `eq`, `contains`†, `and`            |
| `GET /v3/WorkItems`             | `StartDate`                                                                                                                                                       | `ge`, `le`, `and`                   |
| `GET /v3/Timesheets`            | `TimesheetKey` (eq), `StartDate` (gt), `EndDate` (lt), `Status` (eq), `UserKey` (in), `WorkItemKeys` (any/in)                                                     | mixed — see example below           |
| `GET /v3/IndividualTimeEntries` | `TimesheetKey`, `EntityKey`, `WorkItemKey`, `ClientKey`, `UserKey`, `RoleName`, `TaskTypeName` (all eq)                                                           | `eq`, `and`                         |
| `GET /v3/IndividualTimeEntries` | `Date`                                                                                                                                                            | `eq`, `gt`, `ge`, `lt`, `le`, `and` |
| `GET /v3/Users`                 | `Name`, `EmailAddress`                                                                                                                                            | `eq`                                |
| `GET /v3/Invoices`              | `InvoiceStatus` (one of `Approved`, `AwaitingPayment`, `Paid`, `Exported`, `Voided`)                                                                              | `eq`                                |
| `GET /v3/WorkTemplates`         | `Title`, `WorkTypeKey`, `PublishedDate`, `DateModified`, `DateLastWorkItemCreated`, `NumberOfWorkItemsCreated`, `HasScheduledClientTaskGroups`, `DraftHasChanges` | `eq`                                |

† `contains` is not supported for `ClientKey`, `PrimaryStatus`, `WorkScheduleKey`, `WorkTemplateKey`, or `ExternalKey`.

**Timesheets `WorkItemKeys` filter example** (batch multiple keys):

```
GET /v3/Timesheets?$filter=WorkItemKeys/any(x: x in ('2m6pSFxRzcF2', '2y7H6dhQL7mD'))
```

---

### Ordering (`$orderby`)

Append ` desc` to any sortable field for descending order.

| Endpoint                        | Sortable fields                                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `GET /v3/Contacts`              | `FullName`, `LastModifiedDateTime`                                                                    |
| `GET /v3/Organizations`         | `FullName`, `LastModifiedDateTime`                                                                    |
| `GET /v3/ClientGroups`          | `FullName` (default: `ClientGroupKey`)                                                                |
| `GET /v3/WorkItems`             | `StartDate`, `DeadlineDate`                                                                           |
| `GET /v3/Timesheets`            | `StartDate`, `EndDate`                                                                                |
| `GET /v3/IndividualTimeEntries` | `Date`                                                                                                |
| `GET /v3/Invoices`              | `InvoiceDate`, `CreatedAt`, `UpdatedAt`, `InvoiceNumber`                                              |
| `GET /v3/WorkTemplates`         | `WorkTypeKey`, `PublishedDate`, `NumberOfWorkItemsCreated`, `DateLastWorkItemCreated`, `DateModified` |

---

### Expanding related data (`$expand`)

Pass a comma-separated list where multiple values are supported. Only available on the endpoints listed below.

| Endpoint                                                            | `$expand` options                             |
| ------------------------------------------------------------------- | --------------------------------------------- |
| `GET /v3/Contacts/{key}`                                            | `BusinessCards`, `ClientTeam`, `ClientAccess`, `ServiceTypes` |
| `GET /v3/Contacts/GetContactByUserDefinedIdentifier(...)`           | `BusinessCards`, `ServiceTypes`               |
| `GET /v3/ClientGroups/GetClientGroupByUserDefinedIdentifier(...)`   | `BusinessCard`                                |
| `GET /v3/Organizations/{key}`                                       | `BusinessCards`,`ClientTeam`,`Contacts`,`ServiceTypes` |
| `GET /v3/Organizations/GetOrganizationByUserDefinedIdentifier(...)` | `BusinessCards`, `ServiceTypes`               |
| `GET /v3/Timesheets`                                                | `TimeEntries`                                 |
| `GET /v3/Timesheets/{key}`                                          | `TimeEntries`                                 |

---

### Rate Limiting

- **HTTP 429:** `{ "statusCode": "429", "message": "Rate limit is exceeded. Try again in 10 seconds." }`
- Retry after the indicated delay.

---

### Concurrent Update Conflicts

- **HTTP 409:** Any `PUT` or `PATCH` that loses an optimistic-concurrency race against another writer.
- Body: `{ "error": { "code": "4020", "message": "The resource was modified by another request. Refetch the latest version and retry." } }`
- Treat as retryable: re-fetch the resource, reapply the change, resubmit.

---

### RestrictionLevel Permission Errors

- **HTTP 403:** Setting `RestrictionLevel` to `Private` or `Hidden` on Contacts/Organizations without the matching `IncludePrivate`/`IncludeHidden` app permission.
- Body: `{ "error": { "code": "4021", "message": "This API application does not have permission to set RestrictionLevel to Hidden." } }`
- `Public` is always settable; an unrecognized `RestrictionLevel` value returns `400` instead.

---

### UserDefinedIdentifier Lookups

Contacts, Organizations, and ClientGroups support lookup by a custom identifier:

```
GET /v3/Contacts/GetContactByUserDefinedIdentifier(UserDefinedIdentifier='{id}')
GET /v3/Organizations/GetOrganizationByUserDefinedIdentifier(UserDefinedIdentifier='{id}')
GET /v3/ClientGroups/GetClientGroupByUserDefinedIdentifier(UserDefinedIdentifier='{id}')
```

---

## 4. Resource Quick Reference

| Tag                         | Key Endpoints                                                                                                                                                   | Notes                                                                                                                                                                                                                                          |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Billing**                 | `GET /v3/Invoices`, `GET /v3/Invoices/{key}`, `GET /v3/Payments`, `POST /v3/ManualPayments`, `DELETE /v3/ManualPayments/{key}`, `POST /v3/ReverseManualPayment` | Read-only for invoices/payments                                                                                                                                                                                                                |
| **Business Cards**          | `GET /v3/BusinessCards/{key}`, `PUT /v3/BusinessCards/{key}`                                                                                                    | Attached to Contact or Organization. Set `OrganizationKey` on a Contact's BusinessCard to associate the Contact with an Organization. Always `null` on Organization-side cards.                                                                |
| **Client Groups**           | `GET`, `POST /v3/ClientGroups`, `GET/PUT/PATCH /v3/ClientGroups/{key}`                                                                                          | Check `CreateClientGroup` schema for required fields                                                                                                                                                                                           |
| **Comments**                | `GET /v3/Comments('{key}')`                                                                                                                                     | Read-only; OData-style key syntax                                                                                                                                                                                                              |
| **Contacts**                | `GET`, `POST /v3/Contacts`, `GET/PUT/PATCH /v3/Contacts/{key}`                                                                                                  | Required: `FirstName`, `LastName`. Associate with an Organization via `OrganizationKey` on a BusinessCard (see Business Cards). `RestrictionLevel` (`Public`/`Private`/`Hidden`) is writable on POST/PUT/PATCH — Private/Hidden require the app's `IncludePrivate`/`IncludeHidden` permission, otherwise `403`. `PATCH` supports `FirstName`, `MiddleName`, `LastName`, `PreferredName`, `Salutation`, `Suffix`, `RestrictionLevel`.                                                                                                                |
| **Custom Fields**           | `GET/PUT /v3/CustomFieldValues/{EntityKey}`, `GET/POST /v3/CustomFields`, `DELETE /v3/CustomFields/{key}`                                                       | EntityKey is the key of the Contact/Org. Field types: `Text`, `Number`, `Date`, `Boolean`, `Colleague`, `ListSingleSelect`, `ListMultipleSelect`. `Colleague` fields store/return a UserKey (also referred to as UserID elsewhere in the API). |
| **Estimate Summaries** (aka Budgets) | `GET /v3/EstimateSummaries/{WorkItemKey}`, `GET/PATCH /v3/WorkItems/{WorkItemKey}/EstimateSummaries/{EstimateSummaryKey}`                                       | This IS the "budget" endpoint — there is no `/v3/Budget*` path. List is read-only. Single-summary GET/PATCH work per-user: `HourlyRate`, `EstimateMinutes`, `ActualMinutes`, `EstimateAmount` (estimated cost). `EstimateSummaryKey` starting with `0-` is time with no estimate assigned, not a real estimate — GET-only, assign an estimate in-app before it's patchable. `PATCH` supports `EstimateMinutes`, `EstimateAmount`, `HourlyRate` — only one of `EstimateMinutes`/`EstimateAmount` per firm's Time and Budget setting (time vs. amount), not settable via API. Changing `HourlyRate` can reissue `EstimateSummaryKey`; always follow the `OData-EntityId` response header. |
| **Expenses**                | `POST /v3/Expenses`                                                                                                                                             | Create-only — no list, update or delete. All fields required: `Timeline` (`EntityType` one of `WorkItem`/`Contact`/`Organization`, plus `EntityKey`), `ExpenseDate`, `Value`, `BillableValue` (both `>= 0`), `Description` (max 250 chars). No expense type on create.                                                              |
| **Files**                   | `GET /v3/FileList/{EntityType}`, `GET /v3/Files`, `POST /v3/Files`, `GET /v3/FileDetails/{key}`, `GET /v3/FileDetails/{key}/Download`                          | EntityType in path for listing. `FileDetails` looks up a single file by the `FileContextKey` from `FileList`; `Download` redirects (302) to a freshly-tokened download URL.                                                                   |
| **Integrated Workflows**    | `GET /v3/IntegrationTaskDefinitions`, `GET/PUT /v3/IntegrationTasks/{key}`                                                                                      | Restricted to approved integration partners                                                                                                                                                                                                    |
| **Notes**                   | `POST /v3/Notes`, `GET /v3/Notes/{id}`                                                                                                                          | Required: `Subject`, `Body` (HTML supported), `AuthorEmailAddress`                                                                                                                                                                             |
| **Organizations**           | `GET`, `POST /v3/Organizations`, `GET/PUT/PATCH /v3/Organizations/{key}`                                                                                        | Required: `FullName`. `RestrictionLevel` (`Public`/`Private`/`Hidden`) is writable on POST/PUT/PATCH — Private/Hidden require the app's `IncludePrivate`/`IncludeHidden` permission, otherwise `403`. `PATCH` supports `FullName`, `RestrictionLevel`.                                                                                                                                           |
| **Roles**                   | `GET /v3/Roles`                                                                                                                                                  | Read-only. Active, non-default Roles only, sorted by `Name` then `Key`. Max 200 per page.                                                                                                                                                      |
| **Tags**                    | _(no paths in spec — beta, not enabled for all users)_                                                                                                          | —                                                                                                                                                                                                                                              |
| **Teams**                   | `GET /v3/Teams`, `GET /v3/Teams/{TeamKey}`, `POST /v3/Teams/{TeamKey}/AddMembers`, `POST /v3/Teams/{TeamKey}/RemoveMember`                                      | Single-team response includes `Members` (users and sub-teams). `AddMembers`/`RemoveMember` are bound OData actions (always `POST`), idempotent, and users-only — adding/removing a sub-team, or creating/editing a Team itself, is still Karbon-app-only.                          |
| **Tenant Settings**         | `GET /v3/TenantSettings`                                                                                                                                        | Returns valid `ContactTypes`, `WorkTypes`, `WorkStatuses`, `ServiceTypes`, `TenantKey`, `ClientAccessActivated`. Each `ServiceTypes` entry (`Name`, `ServiceTypeKey`, `WorkTypeKeys[]`) groups work types — `WorkTypeKeys` cross-references `WorkTypes[].WorkTypeKey`                                                                                                                                                |
| **Individual Time Entries** | `GET /v3/IndividualTimeEntries`, `GET /v3/IndividualTimeEntries/{IndividualTimeEntryKey}`                                                                       | This is the "logged time" / "hours worked" endpoint — non-aggregated, one record per user/day/task. Prefer this over Timesheets below.                                                                                                                                                                                                                    |
| **Timesheets**              | `GET /v3/Timesheets`, `GET /v3/Timesheets/{key}`                                                                                                                | **Deprecated** — both operations. Returns time aggregated to the tenant's timesheet period (weekly by default), not per-day. Use Individual Time Entries instead. Expand `TimeEntries` for detail if you must use this.                                                                                                                                                                                                                |
| **Users**                   | `GET /v3/Users`, `POST /v3/Users`, `GET /v3/Users/{id}`                                                                                                         | —                                                                                                                                                                                                                                              |
| **Webhook Subscriptions**   | `POST/DELETE /v3/WebhookSubscriptions`, `GET/PATCH/DELETE /v3/WebhookSubscriptions/{type}`                                                                      | One subscription per entity type; 10 retries then auto-cancelled. `WebhookTypes` (instead of `WebhookType`) creates a multi-type subscription at `/v3/WebhookSubscriptions/Multi` — `PATCH` there replaces the full type list. `BatchSize`/`BatchMaxDelaySeconds` on any subscription batch deliveries into one `POST` |
| **Work Items**              | `GET`, `POST /v3/WorkItems`, `GET/PUT/PATCH /v3/WorkItems/{key}`                                                                                                | Most filterable resource                                                                                                                                                                                                                       |
| **Work Schedules**          | `POST /v3/WorkSchedules`, `GET/PUT/PATCH /v3/WorkSchedules/{key}`                                                                                               | For repeating work. `PATCH` supports `ScheduleEndDate` and `AssigneeUserKey` only. `ScheduleDeadlineDateMethod`/`Days`/`MonthMultiple` (GET/POST/PUT only) set a deadline date alongside the existing `ScheduleDueDate*` fields                                                                                                                                                                                    |
| **Work Templates**          | `GET /v3/WorkTemplates`, `GET /v3/WorkTemplates/{key}`                                                                                                          | Read-only                                                                                                                                                                                                                                      |

---

## 5. Key Concepts

### ClientType values

Used when creating or referencing Work Items:

`Contact` | `Organization` | `ClientGroup`

---

### WorkItem required fields (POST and PUT)

`AssigneeEmailAddress`, `Title`, `ClientKey`, `ClientType`, `StartDate`

PATCH supports `Title`, `Description`, `StartDate`, `DueDate`, `DeadlineDate`, `AssigneeEmailAddress`, and `WorkType`. Any other property in the request body returns a `400`.

### WorkItem fee settings

Set billing behaviour via `FeeSettings` in POST/PUT body:

| `FeeType`          | `FeeValue`               |
| ------------------ | ------------------------ |
| `FixedFee`         | The fee amount (decimal) |
| `TimeAndMaterials` | Must be `null`           |
| `NonBillable`      | Must be `null`           |

Hourly rates and time estimates (from `EstimateSummaries`) are read-only at Work Item creation, but a single estimate summary can be updated afterwards via `PATCH /v3/WorkItems/{WorkItemKey}/EstimateSummaries/{EstimateSummaryKey}` — see Estimate Summaries above.

---

### PrimaryStatus vs WorkStatus

Both exist on WorkItems. **Prefer `PrimaryStatus` and `SecondaryStatus`** — these are the recommended fields for tracking work progress. `WorkStatus` is a legacy field.

**PrimaryStatus** values are fixed (not tenant-specific):

| Value            | Meaning                  |
| ---------------- | ------------------------ |
| `Planned`        | Not yet started          |
| `Ready To Start` | Ready to begin           |
| `In Progress`    | Actively being worked on |
| `Waiting`        | Blocked or waiting       |
| `Completed`      | Done                     |

Accepted format differs by where the value is used:

- **Request bodies (POST/PUT):** either form works — `"In Progress"` and `"InProgress"` are both accepted.
- **`$filter`:** only the spaced form (`In Progress`, `Ready To Start`) matches. The no-space form isn't rejected — it silently returns zero rows.
- **Responses:** format is inconsistent by endpoint. `GET /v3/WorkItems` (list) returns the spaced form (`"In Progress"`); `GET /v3/WorkItems/{key}`, and POST/PUT response bodies, return the no-space form (`"InProgress"`). Don't assume one canonical format when parsing responses — check which endpoint produced the value.

**SecondaryStatus** values are tenant-customizable. Valid values come from `GET /v3/TenantSettings`, scoped to a `PrimaryStatus` **and** to a specific `WorkType` — a value valid for the same `PrimaryStatus` under a different `WorkType` will be accepted by the API but silently dropped (comes back `null`), not rejected. Always check the value is listed for the WorkItem's actual `WorkType`, not just its `PrimaryStatus`.

---

### Webhook payload format

```json
{
  "ResourcePermaKey": "{EntityKey}",
  "ResourceType": "{EntityType}",
  "ActionType": "{ActionType}",
  "TimeStamp": "YYYY-MM-DDTHH:mm:ssZ"
}
```

Webhook entity types: `Contact` (also covers ClientGroups, Organizations), `Work`, `Note`, `User`, `IntegrationTask`, `Invoice`, `EstimateSummary`, `CustomField`

### Multi-type and batched webhook subscriptions

Pass `WebhookTypes` (array) instead of `WebhookType` on `POST /v3/WebhookSubscriptions` to subscribe to a set of types under one subscription — mutually exclusive with `WebhookType` (400 if both given). Addressed afterwards at `/v3/WebhookSubscriptions/Multi` for `GET`/`PATCH`/`DELETE`; `PATCH` there does a whole-list replace of `WebhookTypes` and returns `404` if no multi-type subscription exists yet.

`BatchSize` (max `100`) and `BatchMaxDelaySeconds` work on single-type or multi-type subscriptions. With `BatchSize > 1`, delivery wraps events in a batch envelope instead of the single-notification payload above:

```json
{
  "BatchId": "70575b82-9a31-4576-a4e7-61e20c42189d",
  "Count": 3,
  "Events": [
    { "ResourcePermaKey": "3GBF6TnYRc7C", "ResourceType": "WorkItem", "ActionType": "Inserted", "Timestamp": "2026-09-02T03:49:42Z" }
  ]
}
```

`BatchSize` is a hard cap. `BatchMaxDelaySeconds` adds extra delay on top of the standard 60-second dispatch window (`0` = no extra delay). Note `ResourceType` for a Work event in a batch is `WorkItem`, not `Work`.

---

### ClientAccessActivated

Flag in TenantSettings indicating if Karbon for Clients (K4C) is enabled. The `ClientAccess` expand on Contacts is only meaningful when this is `true`.

---

## 6. Tips for Agents

- **Don't derive endpoint paths from `/v3/$metadata`.** It's a dense OData CSDL/EDMX document intended for OData client codegen, not for discovering endpoints from — parsing it directly is a common source of hallucinated, non-existent endpoints. `KarbonAPI.json` (this repo's OpenAPI specification) is the **canonical source of available endpoints** — use the Resource Quick Reference table above for a summary, or the `paths` object in `KarbonAPI.json` for the full, authoritative list.
- **Map everyday terms to their actual resource before searching for an endpoint** — the plain-English word rarely matches the resource name:
  - "budget" / "estimate" → `EstimateSummaries` (there is no `/v3/Budget*` path)
  - "time tracking" / "hours worked" / "logged time" → `IndividualTimeEntries` (not the deprecated `Timesheets`)
- **Always fetch TenantSettings first** if you need WorkTypes, ContactTypes, or SecondaryStatus values — these are tenant-specific. PrimaryStatus values are fixed.
- **Use `UserDefinedIdentifier`** if you control entity creation; it enables reliable lookups without storing Karbon-generated keys.
- **Page with `@odata.nextLink`** — use the URL from the response directly, don't manually increment `$skip`.
- **`$expand` adds latency** — only request it when you need the nested data.
- **Notes body supports HTML** — plain text is fine but HTML formatting is available.
- **Tags are beta** — no API paths are currently exposed in the spec.
- **Colleague custom field values** store a UserKey (same as UserID). To resolve to a name, call `GET /v3/Users/{UserKey}` as a second step — the custom field response does not expand user details.

---

## 7. Workflows

### Workflow 1 — Onboard a new client

**1. Create the Organization**

```
POST /v3/Organizations
{ "FullName": "Acme Corp" }
```

Save the returned `OrganizationKey`.

**2. Create the Contact**

```
POST /v3/Contacts
{
  "FirstName": "Jane",
  "LastName": "Smith",
  "BusinessCards": [{
    "IsPrimaryCard": true,
    "OrganizationKey": "{OrganizationKey}",
    "RoleOrTitle": "CFO",
    "EmailAddresses": ["jane@acme.com"]
  }]
}
```

Save the returned `ContactKey`.

**3. Create a Work Item for the client**

```
POST /v3/WorkItems
{
  "Title": "2025 Tax Return",
  "AssigneeEmailAddress": "advisor@firm.com",
  "ClientKey": "{ContactKey}",
  "ClientType": "Contact",
  "StartDate": "2025-07-01T00:00:00Z",
  "PrimaryStatus": "Ready To Start"
}
```

---

### Workflow 2 — Pull timesheet data for active work

**1. Collect WorkItem keys for all in-progress statuses**

Run one paginated request per status — `PrimaryStatus` only supports `eq`:

```
GET /v3/WorkItems?$filter=PrimaryStatus eq 'Ready To Start'&$top=100
GET /v3/WorkItems?$filter=PrimaryStatus eq 'In Progress'&$top=100
GET /v3/WorkItems?$filter=PrimaryStatus eq 'Waiting'&$top=100
```

Page each using `@odata.nextLink`. Collect all `WorkItemKey` values.

**2. Fetch timesheets for those WorkItems (batched)**

```
GET /v3/Timesheets?$filter=WorkItemKeys/any(x: x in ('{key1}', '{key2}', ...))&$expand=TimeEntries&$top=100
```

Page using `@odata.nextLink`. If key count is very large, chunk into multiple requests.

---

### Workflow 3 — Create and assign a Colleague custom field

**1. Create the field definition**

```
POST /v3/CustomFields
{
  "Name": "Relationship Manager",
  "Type": "Colleague",
  "IsVisibleToContacts": true
}
```

Save the returned `Key` as `{CustomFieldKey}`.

**2. Assign a value to a Contact**

```
PUT /v3/CustomFieldValues/{ContactKey}
{
  "EntityKey": "{ContactKey}",
  "CustomFieldValues": [{
    "Key": "{CustomFieldKey}",
    "Name": "Relationship Manager",
    "Type": "Colleague",
    "Value": ["{UserKey}"]
  }]
}
```

**3. Read back and resolve the user's name**

```
GET /v3/CustomFieldValues/{ContactKey}
```

Extract the `Value[0]` (a UserKey), then:

```
GET /v3/Users/{UserKey}
```

---

### Workflow 4 — Create a Work Item from a Work Template

**1. Find the template key**

```
GET /v3/WorkTemplates?$filter=Title eq 'Annual Tax Return'
```

Save the returned `WorkTemplateKey`.

**2. Create the Work Item referencing the template**

```
POST /v3/WorkItems
{
  "Title": "2025 Annual Tax Return — Acme Corp",
  "AssigneeEmailAddress": "advisor@firm.com",
  "ClientKey": "{ContactKey}",
  "ClientType": "Contact",
  "StartDate": "2025-07-01T00:00:00Z",
  "WorkTemplateKey": "{WorkTemplateKey}"
}
```

The template pre-populates tasks and structure. Required fields (`AssigneeEmailAddress`, `Title`, `ClientKey`, `ClientType`, `StartDate`) must still be provided.

---

### Workflow 5 — Add a monthly repeat schedule to a Work Item

**1. Create the Work Schedule**

```
POST /v3/WorkSchedules
{
  "CreatedFromWorkItemKey": "{WorkItemKey}",
  "RecurrenceFrequency": "Month",
  "CustomFrequencyMultiple": 1,
  "ScheduleStartDate": "2025-08-01T00:00:00Z",
  "ScheduleEndDate": null,
  "ScheduleDueDateMethod": "DaysFromStartDate",
  "ScheduleDueDateDays": 30,
  "PreventStartEndOnWeekend": true,
  "InitializeTasksBeforeStartDateUnits": "Days",
  "InitializeTasksBeforeStartDateMultiple": 7,
  "WorkItemTitleDefinition": "[{\"Text\":\"Monthly Bookkeeping \",\"Variable\":null,\"Format\":null,\"Offset\":0},{\"Text\":null,\"Variable\":\"RepeatPeriod\",\"Format\":\"DD MMM, YYYY - DD MMM, YYYY\",\"Offset\":0}]"
}
```

Save the returned `WorkScheduleKey`.

**2. Link the schedule back to the Work Item**

```
PUT /v3/WorkItems/{WorkItemKey}
{
  "AssigneeEmailAddress": "advisor@firm.com",
  "Title": "Monthly Bookkeeping — Acme Corp",
  "ClientKey": "{ContactKey}",
  "ClientType": "Contact",
  "StartDate": "2025-08-01T00:00:00Z",
  "WorkScheduleKey": "{WorkScheduleKey}"
}
```

> **`WorkItemTitleDefinition`** is a JSON array serialized as a string. Each element is a segment with `Text` (literal string) or `Variable` (dynamic value), plus `Format` (date format) and `Offset`. The example above produces titles like `"Monthly Bookkeeping 01 Jul, 2025 - 31 Jul, 2025"`.

---

### Workflow 6 — Update a Work Item's Primary and Secondary status

`PUT` replaces the whole Work Item, so the [required fields](#workitem-required-fields-post-and-put) must be sent alongside the status change, not just the status fields.

**1. Look up valid SecondaryStatus values for the WorkType**

```
GET /v3/TenantSettings
```

Find the entry for the Work Item's `WorkType` (e.g. `"Payroll"`) and note the SecondaryStatus values listed under the target `PrimaryStatus`.

**2. Send the update**

```
PUT /v3/WorkItems/{WorkItemKey}
{
  "AssigneeEmailAddress": "advisor@firm.com",
  "Title": "Payroll 31 Aug - 15 Sep 2025",
  "ClientKey": "{ContactKey}",
  "ClientType": "Contact",
  "StartDate": "2025-08-31T00:00:00Z",
  "PrimaryStatus": "InProgress",
  "SecondaryStatus": "Send client requests"
}
```

`PrimaryStatus` accepts either `"InProgress"` or `"In Progress"` in the request body — see [PrimaryStatus vs WorkStatus](#primarystatus-vs-workstatus). `SecondaryStatus` must be one of the values TenantSettings lists for **both** this `PrimaryStatus` and this Work Item's `WorkType` — a value valid under a different WorkType is accepted but silently dropped (`null` in the response), not rejected.

---

### Workflow 7 — Subscribe to Invoice webhook

**1. Create the subscription**

```
POST /v3/WebhookSubscriptions
{
  "TargetUrl": "https://yourapp.example.com/webhooks/karbon",
  "WebhookType": "Invoice",
  "SigningKey": "your-signing-key-min-16-chars"
}
```

Only one subscription per `WebhookType` is allowed. If one already exists, delete it first.

**2. Handle incoming payloads**

```json
{
  "ResourcePermaKey": "{InvoiceKey}",
  "ResourceType": "Invoice",
  "ActionType": "Updated",
  "TimeStamp": "2025-07-01T10:00:00Z"
}
```

Respond with HTTP 2xx within the timeout — 10 failed deliveries auto-cancels the subscription.

**3. Check or remove the subscription**

```
GET    /v3/WebhookSubscriptions/Invoice
DELETE /v3/WebhookSubscriptions/Invoice
```

**4. Re-subscribe if auto-cancelled**

Poll `GET /v3/WebhookSubscriptions/Invoice` — a 404 means the subscription was cancelled. Re-POST to reinstate.

---

### Workflow 8 — Update contact information (email, phone, address)

Contact details (email, phone, address) are stored on the Contact's **BusinessCard**, not directly on the Contact record.

**1. Get the BusinessCardKey**

```
GET /v3/Contacts/{ContactKey}?$expand=BusinessCards
```

Find the relevant BusinessCard and save its `BusinessCardKey`.

**2. Update the BusinessCard**

```
PUT /v3/BusinessCards/{BusinessCardKey}
{
  "EntityType": "Contact",
  "EntityKey": "{ContactKey}",
  "IsPrimaryCard": true,
  "OrganizationKey": "{OrganizationKey}",
  "EmailAddresses": ["jane.smith@acme.com"],
  "PhoneNumbers": [
    { "Number": "+14155550100", "CountryCode": "US", "Label": "Work" }
  ],
  "Addresses": [
    {
      "AddressLines": "123 Main St",
      "City": "San Francisco",
      "StateProvinceCounty": "CA",
      "ZipCode": "94105",
      "CountryCode": "US",
      "Label": "Physical"
    }
  ]
}
```

> PUT replaces the entire BusinessCard — include all existing fields you want to keep, not just the changed ones. Valid `PhoneNumbers.Label` values: `Work`, `Mobile`, `Office`, `Fax`, `Home`, `Other`. Valid `Addresses.Label` values: `Physical`, `Mailing`, `Legal`, `Home`. Country codes are ISO 3166-1 alpha-2 (e.g. `US`, `AU`, `GB`).

> When `PUT`-ing to `/v3/Contacts/{ContactKey}` or `/v3/Organizations/{OrganizationKey}` to update other fields, the `BusinessCards` array is optional — omit it to leave the existing Business Cards untouched.
