> For the complete documentation index, see [llms.txt](https://developer.laurel.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.laurel.ai/guides/public-api-endpoints.md).

# Public Api Endpoints

This is a document for all of our public API endpoints. Your specific endpoints will be provided to you by our solutions delivery team.

## Identity Service

* <https://identity.laurel.ai>

## Time Service

* <https://api.laurel.ai/time/>

### Starred initiatives

These endpoints add matters to, remove matters from, or list a user's starred (favorite) matters. Star and unstar are incremental: only the matters listed in the request are affected, and the rest of the user's starred list is left unchanged. Authenticate with an OAuth2 client-credentials token as described in [Authentication](/guides/authentication.md).

The `customerId` in the URL identifies the customer whose matters are being updated. The request identifies one target user with their customer-system timekeeper external ID. Each initiative is identified by its PMS matter external ID.

These endpoints only update the target user's starred state. They never grant or change access to a matter.

#### Star initiatives

```
POST /api/v1/public/customers/{customerId}/initiative-props/star
```

Adds the listed matters to the target user's starred list. Matters already starred are unaffected (the item still returns `200`).

Request:

```json
{
  "timekeeperExternalId": "TK-12345",
  "initiativeExternalIds": [
    "MATTER-1001",
    "MATTER-1002"
  ]
}
```

| Field                   | Type      | Required | Description                                                                                                       |
| ----------------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `timekeeperExternalId`  | string    | Yes      | The target user's customer-system timekeeper external ID.                                                         |
| `initiativeExternalIds` | string\[] | Yes      | PMS matter external IDs. Must contain between 1 and 100 IDs per request. Duplicate IDs are silently deduplicated. |

#### Unstar initiatives

```
POST /api/v1/public/customers/{customerId}/initiative-props/unstar
```

Removes the listed matters from the target user's starred list. Matters that were not starred are unaffected (the item still returns `200`). The request body and validation rules are the same as for the star endpoint.

```json
{
  "timekeeperExternalId": "TK-12345",
  "initiativeExternalIds": [
    "MATTER-1001"
  ]
}
```

#### Response

Both endpoints return `207 Multi-Status`. Each requested matter has an individual result:

```json
{
  "response": [
    {
      "initiativeExternalId": "MATTER-1001",
      "status": 200
    },
    {
      "initiativeExternalId": "MATTER-1002",
      "status": 403,
      "errorCode": 20083,
      "errorMessage": "initiative access denied"
    }
  ]
}
```

| Per-item status | `errorCode` | Meaning                                                                                                                                                     |
| --------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`           | —           | Star or unstar succeeded. Returned status code will be `200` independent of the matter's previous star status; operations are idempotent and safe to retry. |
| `403`           | `20083`     | The target user does not have access to the matter.                                                                                                         |
| `404`           | `20080`     | The matter external ID was not found for the specified customer.                                                                                            |
| `422`           | `20081`     | The matter is inactive.                                                                                                                                     |
| `500`           | —           | Unexpected processing error.                                                                                                                                |

Requests are limited to 100 matters. Duplicate IDs in a request are silently deduplicated, so the response contains one entry per distinct ID. Clients should page larger sets across multiple requests. Star and unstar operations are idempotent and safe to retry.

If the supplied `timekeeperExternalId` cannot be resolved, the entire request fails with HTTP `404`; no per-item response is returned. If it resolves to multiple users, the entire request fails with HTTP `409`. An internal timekeeper-resolution failure returns HTTP `500`.

#### Get starred initiatives

```
GET /api/v1/public/customers/{customerId}/initiative-props/starred?timekeeperExternalId=TK-12345&limit=2500
```

Returns the target user's currently starred matters as PMS matter external IDs. Only matters the user still has access to are included. Matters the timekeeper created in Laurel rather than sourced from the PMS are never returned, so this list can be shorter than the starred list the timekeeper sees in Laurel.

| Query parameter        | Type    | Required | Description                                                                          |
| ---------------------- | ------- | -------- | ------------------------------------------------------------------------------------ |
| `timekeeperExternalId` | string  | Yes      | The target user's customer-system timekeeper external ID.                            |
| `limit`                | integer | No       | Number of raw pinned props to fetch. Must be between 1 and 2,500. Defaults to 2,500. |
| `cursor`               | string  | No       | Opaque cursor returned as `nextCursor` by the previous page.                         |

Response (`200 OK`):

```json
{
  "initiativeExternalIds": [
    "MATTER-1001",
    "MATTER-1002"
  ],
  "nextCursor": null
}
```

When more results are available, `nextCursor` contains an opaque cursor for the next page. It is `null` on the last page. Always continue paging until `nextCursor` is `null` — a page may contain fewer than `limit` items (even zero) while more results remain, because inaccessible and non-PMS matters are filtered out after the page is fetched. An invalid cursor returns HTTP `400`. An unknown `timekeeperExternalId` returns HTTP `404`; an ambiguous timekeeper external ID returns HTTP `409`; and an internal resolution failure returns HTTP `500`. The caller's credentials must be scoped to the `customerId` in the URL.

## Ingestion Service

* <https://api.laurel.ai/ingestion/>

### Unreleased entries

These endpoints create, update and delete time entries from an external system, such as a PTO or scheduling tool. Entries land in Laurel as **unreleased**: they appear on the timekeeper's timesheet for review, and the timekeeper releases them to the PMS as usual. Authenticate with an OAuth2 client-credentials token as described in [Authentication](/guides/authentication.md). Your API client needs the `CustomerAnyUserAnyEntryWrite` or `CustomerOwnUserAnyEntryWrite` permission, and its credentials must be scoped to the `customerId` in the URL.

Every entry is identified by two values you choose:

* `sourceSystem` names the system the entries come from, for example `workday`. It is 2 to 32 characters, lowercase letters, digits, `-` and `_`, starting with a letter or digit (`^[a-z0-9][a-z0-9_-]{1,31}$`). Uppercase input is lowercased, so send it in lowercase to avoid confusion.
* `externalId` is the entry's ID in that system. Together with `sourceSystem` it is the key you use to update or delete the entry later, so it must not change. It must be unique per `sourceSystem` across all timekeepers, not just within one timekeeper.

All three endpoints are asynchronous. They validate the request, accept it with `202 Accepted` and no body, and process each entry a few seconds later. A request that fails validation, such as a missing required field, an invalid `sourceSystem` or more than 100 entries, returns `400` and nothing is processed. An entry that fails after the `202` (for example, an unknown matter, code or user) is skipped and is not reported back to the caller, so check your IDs before sending. Each request takes at most 100 entries; split larger sets across several requests.

#### Create unreleased entries

```
POST /api/v1/customers/{customerId}/entries/import-unreleased
```

Request:

```json
{
  "sourceSystem": "workday",
  "entries": [
    {
      "userId": "64b7f1c2e4b0a1d2c3e4f5a6",
      "externalId": "PTO-2026-10-20-TK12345",
      "workDate": "2026-10-20",
      "durationInSeconds": 28800,
      "initiativeExternalId": "MATTER-1001",
      "codeExternalIds": {
        "ACTIVITY": "A101"
      },
      "narrative": "Paid time off",
      "notes": "Approved in Workday"
    }
  ]
}
```

| Field                            | Type      | Required | Description                                                                                                                                                                       |
| -------------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sourceSystem`                   | string    | Yes      | The system the entries come from. See above.                                                                                                                                      |
| `idempotencyKey`                 | string    | No       | Accepted but not currently used. Duplicates are detected by `sourceSystem` + `externalId` instead.                                                                                |
| `entries`                        | object\[] | Yes      | Up to 100 entries.                                                                                                                                                                |
| `entries[].userId`               | string    | Yes      | The Laurel user ID of the timekeeper (24 hexadecimal characters), as returned by the Identity Service users endpoints.                                                            |
| `entries[].externalId`           | string    | Yes      | The entry's ID in the source system.                                                                                                                                              |
| `entries[].workDate`             | string    | Yes      | The day the work was done, as an ISO 8601 date (`YYYY-MM-DD`).                                                                                                                    |
| `entries[].durationInSeconds`    | integer   | Yes      | Duration from 0 to 86,400. Laurel rounds it up to the matter's billing increment, so 25 minutes on a 6-minute matter becomes 30 minutes.                                          |
| `entries[].initiativeExternalId` | string    | Yes      | The PMS matter external ID. The matter must already exist in Laurel.                                                                                                              |
| `entries[].codeExternalIds`      | object    | No       | Codes to put on the entry, as a map from code type external ID to code external ID, for example `{"ACTIVITY": "A101", "PHASE": "L100"}`. Every code must already exist in Laurel. |
| `entries[].narrative`            | string    | No       | The entry narrative. Omit it rather than sending `""`.                                                                                                                            |
| `entries[].notes`                | string    | No       | Internal notes on the entry. Omit it rather than sending `""`.                                                                                                                    |

Creating is idempotent. If an entry with the same `sourceSystem` and `externalId` already exists, it is left unchanged, so it is safe to resend a batch after a timeout or error. To change an existing entry, use the update endpoint.

A deleted entry keeps its `externalId`, so it can't be used again. Creating an entry with the same `sourceSystem` and `externalId` still returns `202`, but no entry is created. To add it back, send it with a new `externalId`.

If any entry in the request has no `initiativeExternalId`, the request returns `400`, but other entries in the same request may already have been accepted. Fix the entry and resend the whole batch; the entries already created are skipped.

#### Update unreleased entries

```
PATCH /api/v1/customers/{customerId}/entries/external/{sourceSystem}
```

Updates entries previously created with the same `sourceSystem`. Only the fields you send are changed.

```json
{
  "entries": [
    {
      "userId": "64b7f1c2e4b0a1d2c3e4f5a6",
      "externalId": "PTO-2026-10-20-TK12345",
      "durationInSeconds": 14400,
      "narrative": "Paid time off (half day)"
    }
  ]
}
```

| Field                            | Type    | Required | Description                                                                        |
| -------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------- |
| `entries[].userId`               | string  | Yes      | The Laurel user ID of the timekeeper.                                              |
| `entries[].externalId`           | string  | Yes      | The `externalId` the entry was created with.                                       |
| `entries[].workDate`             | string  | No       | New work date (`YYYY-MM-DD`).                                                      |
| `entries[].durationInSeconds`    | integer | No       | New total duration from 0 to 86,400, rounded up to the matter's billing increment. |
| `entries[].initiativeExternalId` | string  | No       | New PMS matter external ID.                                                        |
| `entries[].codeExternalIds`      | object  | No       | Replaces all codes on the entry, in the same format as on create.                  |
| `entries[].narrative`            | string  | No       | New narrative. Send `""` to clear it.                                              |
| `entries[].notes`                | string  | No       | New notes. Cannot be `""`.                                                         |

Entries that cannot be found, including deleted ones, are skipped. To bring back a deleted entry, create it again.

#### Delete unreleased entries

```
DELETE /api/v1/customers/{customerId}/entries/external/{sourceSystem}
```

```json
{
  "entries": [
    {
      "userId": "64b7f1c2e4b0a1d2c3e4f5a6",
      "externalId": "PTO-2026-10-20-TK12345"
    }
  ]
}
```

Deleting is idempotent: an entry that does not exist or is already deleted counts as success.

#### Released entries

Once a timekeeper releases an entry, it belongs to the PMS and these endpoints no longer change it:

* Updates are skipped for an entry that is released, or that was released before and is now being edited in Laurel.
* Deletes are skipped for an entry that is currently released.

The `allowReleasedOverride` field in the update and delete schemas is not currently supported and has no effect.

### Goals

These endpoints load timekeeper goals from your practice management system (PMS) into Laurel. They cover the targets themselves, catch-up hours achieved before the firm started using Laurel, and the date each timekeeper starts working toward their goals. Authenticate with an OAuth2 client-credentials token as described in [Authentication](/guides/authentication.md). Your API client needs the `CustomerAnyUserAnyTimekeeperGoalWrite` or `CustomerOwnUserAnyTimekeeperGoalWrite` permission, and its credentials must be scoped to the `customerId` in the URL.

Laurel turns on goal imports for the firm. Records sent before that are dropped without being reported back, so send them again once imports are on. A daily full sync does this on its own. Imports don't need the Goals setting in Laurel admin, which is what shows goals to timekeepers, so you can load goals before the firm switches that on.

Timekeepers and groups are referenced by your PMS IDs:

* `timekeeperId` is the timekeeper's ID in your PMS, the same timekeeper external ID stored on the Laurel user. Exactly one Laurel user must have it.
* `groupExternalId` is the external ID of a Laurel user group, as sent to the user groups endpoints.

Load users and user groups first. A record that names a timekeeper or group Laurel does not know yet is refused, and the next sync picks it up once the user or group exists.

All three endpoints are asynchronous. They validate the request, accept it with `202 Accepted` and no body, and process each record a few seconds later. A request that fails validation, such as a missing required field, an unknown enum value, more hours than a year has, a repeated ID, or more than 100 records, returns `400` and nothing is processed. A record that fails after the `202` (for example, an unknown timekeeper or group, or monthly hours that don't add up) is refused on its own and is not reported back to the caller, so check your IDs before sending. Each request takes at most 100 records; split larger sets across several requests.

Sending the same data again is safe, so a daily full sync works. A record that has not changed since it was last sent is skipped, and a changed record replaces the stored one. There are no delete endpoints.

Hours can have decimals. Laurel stores whole seconds and rounds to the nearest second. Every hours value is between 0 and 8,784, the hours in a leap year.

#### Import goals

```
POST /api/v1/customers/{customerId}/goals
```

Request:

```json
{
  "goals": [
    {
      "externalId": "GOAL-2027-FIRM-BILLABLE",
      "scope": { "type": "firm" },
      "billingType": "billable",
      "period": { "type": "calendar", "cycleYear": 2027 },
      "annualHours": 1900
    },
    {
      "externalId": "GOAL-2027-LITIGATION-TOTAL",
      "scope": { "type": "group", "groupExternalId": "LITIGATION" },
      "billingType": "total",
      "period": { "type": "fiscal", "cycleYear": 2027 },
      "annualHours": 2100
    },
    {
      "externalId": "GOAL-2027-TK12345-BILLABLE",
      "scope": { "type": "user", "timekeeperId": "TK12345" },
      "billingType": "billable",
      "period": { "type": "calendar", "cycleYear": 2027 },
      "annualHours": 1800
    }
  ]
}
```

| Field                           | Type      | Required    | Description                                                                                                                                 |
| ------------------------------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `goals`                         | object\[] | Yes         | 1 to 100 goals. Each `externalId` can appear once per request.                                                                              |
| `goals[].externalId`            | string    | Yes         | The goal's ID in your PMS, up to 256 characters. It is the key for later updates, so it must not change.                                    |
| `goals[].scope.type`            | string    | Yes         | Who the goal applies to: `firm`, `group` or `user`.                                                                                         |
| `goals[].scope.groupExternalId` | string    | For `group` | The user group's external ID. Send it only for `group` goals.                                                                               |
| `goals[].scope.timekeeperId`    | string    | For `user`  | The timekeeper's PMS ID. Send it only for `user` goals.                                                                                     |
| `goals[].billingType`           | string    | Yes         | Which hours count toward the goal: `billable`, `non-billable` or `total` (both).                                                            |
| `goals[].period.type`           | string    | Yes         | `calendar` for January to December, or `fiscal` for the firm's fiscal year. A fiscal goal needs the firm's fiscal year start set in Laurel. |
| `goals[].period.cycleYear`      | integer   | Yes         | The year the cycle ends in. For a fiscal year from July 2026 to June 2027, send `2027`. Past years are accepted.                            |
| `goals[].annualHours`           | number    | Yes         | The target for the whole cycle, from 0 to 8,784.                                                                                            |
| `goals[].months`                | object\[] | No          | Monthly targets, 1 to 12 months. Omit it to spread the annual target evenly.                                                                |
| `goals[].months[].month`        | string    | Yes         | A calendar month inside the cycle, as `YYYY-MM`. Each month can appear once.                                                                |
| `goals[].months[].hours`        | number    | Yes         | The target for that month. The months must add up to `annualHours`.                                                                         |

A scope ID that does not match the scope type returns `400`. For example, a `firm` goal that also carries a `timekeeperId` is refused rather than stored as a firm-wide goal. `null` is treated the same as leaving the field out.

A goal is one scope, billing type and cycle. Sending the same `externalId` again updates that goal. If an admin already created that goal in Laurel, the first import takes it over instead of adding a second one. If two different `externalId` values describe the same goal, the first is kept and the second is refused.

#### Import catch-up hours

```
POST /api/v1/customers/{customerId}/goal-adjustments
```

Catch-up hours are time a timekeeper achieved before they started using Laurel. They are added to the timekeeper's progress so it starts from the right place. Laurel counts the timekeeper's own entries from their go-live day on, and the catch-up hours cover the days before it.

Request:

```json
{
  "adjustments": [
    {
      "externalId": "CATCHUP-TK12345-BILLABLE",
      "timekeeperId": "TK12345",
      "billingType": "billable",
      "asOfDate": "2026-10-01",
      "hours": 420.5,
      "months": [
        { "month": "2026-07", "hours": 140 },
        { "month": "2026-08", "hours": 150.5 },
        { "month": "2026-09", "hours": 130 }
      ]
    }
  ]
}
```

| Field                        | Type      | Required | Description                                                                                                                                                                                                                                                                                                                           |
| ---------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adjustments`                | object\[] | Yes      | 1 to 100 records. Each `externalId` can appear once per request.                                                                                                                                                                                                                                                                      |
| `adjustments[].externalId`   | string    | Yes      | The record's ID in your PMS, up to 256 characters.                                                                                                                                                                                                                                                                                    |
| `adjustments[].timekeeperId` | string    | Yes      | The timekeeper's PMS ID.                                                                                                                                                                                                                                                                                                              |
| `adjustments[].billingType`  | string    | Yes      | `billable` or `non-billable`. A `total` goal adds up both, so it has no catch-up of its own.                                                                                                                                                                                                                                          |
| `adjustments[].asOfDate`     | string    | Yes      | The day the timekeeper started using Laurel, as `YYYY-MM-DD`. The catch-up hours cover the days before it, and entries released from this day on count as usual. It also decides which cycle the hours count toward, in the firm's default goal period (calendar or fiscal). A fiscal firm needs its fiscal year start set in Laurel. |
| `adjustments[].hours`        | number    | Yes      | The catch-up hours, from 0 to 8,784.                                                                                                                                                                                                                                                                                                  |
| `adjustments[].months`       | object\[] | No       | The hours by month, 1 to 12 months, each `YYYY-MM` and inside the same cycle as `asOfDate`, none after its month. The months must add up to `hours`.                                                                                                                                                                                  |

Each timekeeper has one catch-up record per billing type and cycle. Sending the same `externalId` again replaces its hours instead of adding to them. A new `externalId` for the same timekeeper, billing type and cycle replaces the record that holds that cycle. Changing an existing record so that it lands on a cycle another record already holds is refused.

#### Import goal start dates

```
POST /api/v1/customers/{customerId}/goal-start-dates
```

A timekeeper who starts partway through a cycle, for example a new hire, is only measured against the part of the target from their start date.

Request:

```json
{
  "startDates": [
    {
      "timekeeperId": "TK12345",
      "startDate": "2027-04-15",
      "externalId": "START-TK12345"
    }
  ]
}
```

| Field                       | Type      | Required | Description                                                                                             |
| --------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `startDates`                | object\[] | Yes      | 1 to 100 records. Each `timekeeperId` can appear once per request.                                      |
| `startDates[].timekeeperId` | string    | Yes      | The timekeeper's PMS ID.                                                                                |
| `startDates[].startDate`    | string    | Yes      | The day the timekeeper starts working toward their goals, as `YYYY-MM-DD`.                              |
| `startDates[].externalId`   | string    | No       | The record's ID in your PMS, up to 256 characters. Each `externalId` can belong to only one timekeeper. |

Each timekeeper has one start date. Sending a new one replaces it.
