> For the complete documentation index, see [llms.txt](https://docs.joinflo.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.joinflo.com/flo-apis/matter-api/overview.md).

# Overview

## Matter API

The Matter API ingests a CSV of matter entries into a Perform review cycle. Each row in the file represents one attorney's work on a matter for a client; entries with the same `(Matter ID, Client Name)` share a single **Client Matter** parent (its `Matter Name` and `Description`).

There are two operations — **upsert** and **validate** — each with a recommended endpoint and a deprecated legacy endpoint:

| Operation               | Recommended                         | Deprecated                       |
| ----------------------- | ----------------------------------- | -------------------------------- |
| Write to a review cycle | `POST /matters-csv/upsert`          | `POST /matters-csv/upload-csv`   |
| Pre-flight validation   | `POST /matters-csv/validate-upsert` | `POST /matters-csv/validate-csv` |

**Use `/upsert` and its pre-flight `/validate-upsert`.** The legacy endpoints are retained for backwards compatibility only and behave differently in ways that make retries and re-uploads unsafe (see Choosing the right endpoint).

Pair the validation endpoint with the write endpoint it targets: `/validate-upsert` mirrors the rules `/upsert` enforces (including in-file duplicate detection), while `/validate-csv` mirrors the older `/upload-csv` rules and will not catch upsert-key duplicates within a file.

***

### Choosing the right endpoint

|                                              | `POST /matters-csv/upsert` (recommended)                                                                                                                                                                                                                                                                            | `POST /matters-csv/upload-csv` (deprecated)                                                                                                                                                                                          |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Idempotency**                              | Safe to retry. Uploading the same CSV twice produces the same end state.                                                                                                                                                                                                                                            | Not idempotent in the useful sense — rows whose `Matter ID` already exists on the cycle are silently skipped, so a corrected re-upload is a no-op for those rows.                                                                    |
| **Uniqueness key**                           | `(Matter ID, Client Name, Attorney Email)` — one entry per attorney's work on a client matter.                                                                                                                                                                                                                      | `(Matter ID, Matter Name, Attorney Email, Billing Attorney Email, Client Name)` — the identifier the endpoint uses to decide whether a row already exists on the target review cycle.                                                |
| **Existing matches**                         | Non-key fields (`Matter Name`, `Billing Attorney Email`, `Start Date`, `End Date`, `Hours Billed`, `Description`) are updated in place. The entry's id is preserved, so review-cycle assignments referencing the entry survive. Rows already matching the stored entry are no-ops and are not counted in `updated`. | Silently skipped. Corrections to `Start Date`, `End Date`, `Hours Billed`, or `Description` cannot be applied via re-upload — the existing entry is left untouched.                                                                  |
| **Shared Client Matter fields**              | `Matter Name` and `Description` refresh on the shared `(Matter ID, Client Name)` parent, updating every entry beneath it.                                                                                                                                                                                           | Not refreshed.                                                                                                                                                                                                                       |
| **Duplicates within one file**               | Rejected. Two rows sharing the upsert key `(Matter ID, Client Name, Attorney Email)` fail validation regardless of whether other fields agree.                                                                                                                                                                      | Exact-duplicate rows (every field identical) are silently collapsed to one entry. Rows sharing `(Matter ID, Client Name, Attorney Email)` but differing on non-key fields are not detected at the CSV validation layer.              |
| **Rows disagreeing on Client Matter fields** | Rejected. Two rows with the same `(Matter ID, Client Name)` that disagree on `Matter Name` or `Description` fail validation.                                                                                                                                                                                        | Cross-row check is limited to `Matter Name` across rows sharing a `Matter ID` (regardless of `Client Name`) — disagreement rejects the file. `Description` is never compared across rows; `Client Name` is not compared across rows. |
| **Response body**                            | JSON: `{ created, updated }`.                                                                                                                                                                                                                                                                                       | Empty on success.                                                                                                                                                                                                                    |
| **Row-count limit**                          | 10,000 rows.                                                                                                                                                                                                                                                                                                        | 10,000 rows.                                                                                                                                                                                                                         |
| **Companion pre-flight**                     | `POST /matters-csv/validate-upsert`                                                                                                                                                                                                                                                                                 | `POST /matters-csv/validate-csv`                                                                                                                                                                                                     |

***

### CSV file format

Applies to all four endpoints.

* **Encoding:** UTF-8. A leading BOM is tolerated and stripped on read. Line endings may be LF or CRLF.
* **Size limit:** file must be less than 10 MB.
* **Row limit:** at most 10,000 data rows.
* **Header row:** required. Column names must include all nine columns below. Matching is **case-insensitive** and **column order does not matter** — columns may appear in any order.

  ```
  Matter ID,Matter Name,Attorney Email,Billing Attorney Email,Start Date,End Date,Hours Billed,Client Name,Description
  ```
* **Escaping:** field values containing a comma, double quote, or newline must be wrapped in double quotes. Embedded double quotes are escaped by doubling. Example — a `Description` of `Smith, Jones & Associates` must be written as `"Smith, Jones & Associates"`.
* **Whitespace:** leading and trailing whitespace in field values is trimmed. Whitespace-only values are treated as absent — persisted as `null` for optional columns; rejected as missing for required columns.
* **Blank rows:** fully blank rows are ignored.

#### Columns

Each subsequent row corresponds to one matter entry.

| Column                   | Type             | Required | Rule                                                                                                                                                                                                                                                                                                |
| ------------------------ | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Matter ID`              | String           | Required | Together with `Client Name`, identifies the Client Matter this entry rolls up to. Multiple rows may share the same `(Matter ID, Client Name)` — one row per attorney working the matter. When they do, all Client Matter fields (`Matter Name`, `Description`) must be identical across those rows. |
| `Matter Name`            | String           | Required | Max 255 characters. A Client Matter field: must be identical across all rows sharing the same `(Matter ID, Client Name)`.                                                                                                                                                                           |
| `Attorney Email`         | String           | Required | Must match an existing Perform member's email.                                                                                                                                                                                                                                                      |
| `Billing Attorney Email` | String           | Required | Must match an existing Perform member's email.                                                                                                                                                                                                                                                      |
| `Start Date`             | Date             | Optional | When present, format must be `M/d/yyyy` (e.g. `6/30/2025`). Blank persists as `null`.                                                                                                                                                                                                               |
| `End Date`               | Date             | Optional | When present, format must be `M/d/yyyy` (e.g. `10/5/2025`). When both dates are present, `End Date` must be on or after `Start Date`. Blank persists as `null`.                                                                                                                                     |
| `Hours Billed`           | Integer or Float | Required | Between 0 and 999999.99 inclusive. Values are written in US format: `.` as the decimal separator, and an optional `,` as a thousands separator (e.g. `1,234.56`). European-style formatting (`1.234,56`) is not accepted.                                                                           |
| `Client Name`            | String           | Required | Max 255 characters. Together with `Matter ID`, identifies the Client Matter this entry rolls up to.                                                                                                                                                                                                 |
| `Description`            | String           | Optional | Max 500 characters. This is the matter's description, not a description of the individual attorney's work on that matter. A Client Matter field: must be identical across all rows sharing the same `(Matter ID, Client Name)`.                                                                     |

***

### `POST /matters-csv/upsert`

Upserts a matter entry for each row in the CSV. **Recommended.**

Uniqueness of a matter entry within a review cycle is by the combined `(Matter ID, Client Name, Attorney Email)` key.

#### Request

* **Content-Type:** `multipart/form-data`
* **Query parameter:**
  * `reviewCycleId` (UUID, required) — the review cycle to associate the upserted entries with.
* **Form parts:**
  * `file` (required) — CSV file, format as above.

#### Behavior

* If a matter entry with the composite key already exists in the cycle, its non-key fields are updated in place when they differ from the row. Rows whose non-key fields already match the stored entry are no-ops and are **not** counted in `updated`. The entry's id is preserved, so any existing review-cycle assignments referencing the entry survive.
* If no such entry exists, a new one is created.
* Multiple entries under the same `(Matter ID, Client Name)` (different `Attorney Email`s) are supported — they share a single Client Matter parent whose canonical `Matter Name` and `Description` refresh on each upsert.

#### Idempotency

* Uploading the same CSV twice produces the same end state — safe to retry.
* Non-key fields updated on the entry: `Matter Name`, `Billing Attorney Email`, `Start Date`, `End Date`, `Hours Billed`, `Description`.
* `Matter Name` and `Description` also refresh on the shared Client Matter parent; all existing entries under the same `(Matter ID, Client Name)` see the update.

#### Validation

All rows must pass validation or the entire upload is rejected — no partial commits.

Rows are rejected when:

* Any field-level validation fails (see per-column rules above).
* Two rows share the same upsert key `(Matter ID, Client Name, Attorney Email)`. Each matter entry must appear in the CSV at most once.
* Two rows share the same Client Matter `(Matter ID, Client Name)` but disagree on any Client Matter field (`Matter Name` or `Description`).

For pre-flight validation without committing, use `POST /matters-csv/validate-upsert` — it returns per-row, per-column error details.

#### Responses

**`200 OK`** — matter entries upserted successfully.

```json
{
  "created": 12,
  "updated": 3
}
```

| Field     | Type    | Description                                                                                                                                                    |
| --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `created` | integer | Number of matter entries newly created by this upsert.                                                                                                         |
| `updated` | integer | Number of existing matter entries whose stored fields were changed by this upsert. Rows whose non-key fields already matched the stored entry are not counted. |

**`400 Bad Request`** — the CSV was rejected. Causes:

* Unsupported file format or empty file.
* File exceeds 10 MB or contains more than 10,000 rows.
* One or more rows failed field-level validation.
* Two rows share the same upsert key `(Matter ID, Client Name, Attorney Email)`.
* Two rows share the same Client Matter `(Matter ID, Client Name)` but disagree on any Client Matter field (`Matter Name`, `Description`).

Use `POST /matters-csv/validate-upsert` to identify the specific rows and columns with errors.

**`401 Unauthorized`** / **`403 Forbidden`** — see Authentication.

**`404 Not Found`** — no review cycle exists for the given `reviewCycleId`.

***

### `POST /matters-csv/validate-upsert`

Pre-flight-validates a matter entry CSV for the upsert endpoint. Does not persist any data.

Validates against the same rules `/upsert` enforces — including in-file uniqueness on `(Matter ID, Client Name, Attorney Email)` and Client Matter field agreement across rows sharing the same `(Matter ID, Client Name)`.

#### Request

* **Content-Type:** `multipart/form-data`
* **Form parts:**
  * `file` (required) — CSV file, format as above.

No `reviewCycleId` is required — validation is against the file's structure and rules, not against an existing cycle's contents.

#### Responses

**`200 OK`** — validation performed. Response includes row numbers and column names that contain errors.

```json
{
  "numValidRows": 1,
  "rowErrors": [
    {
      "rowNumber": 1,
      "columnsWithErrors": ["Matter Name", "Attorney Email"]
    }
  ],
  "fileError": null
}
```

| Field                           | Type             | Description                                                                                                                                  |
| ------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `numValidRows`                  | integer          | Number of rows in the CSV that pass all validation checks.                                                                                   |
| `rowErrors`                     | array            | Details for each row containing errors. Empty if no rows have errors.                                                                        |
| `rowErrors[].rowNumber`         | integer          | The row number containing the error.                                                                                                         |
| `rowErrors[].columnsWithErrors` | array of string  | Column names that failed validation. Email columns fail validation if the address is not associated with an existing Perform member account. |
| `fileError`                     | object, nullable | Present when a file-level constraint is violated. See File error object. `null` when there is no file-level error.                           |

**`401 Unauthorized`** / **`403 Forbidden`** — see Authentication.

***

### File error object

When either validation endpoint (`/validate-upsert` or `/validate-csv`) reports a file-level error, the `fileError` field carries this shape:

| Field         | Type              | Description                                                                                                            |
| ------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `type`        | string (enum)     | One of the four error types below.                                                                                     |
| `rowNumber`   | integer, nullable | Populated only for `COLUMN_COUNT_MISMATCH` — the 1-based number of the offending data row. `null` for the other types. |
| `description` | string            | Human-readable description of the error.                                                                               |

**Error types:**

* `INVALID_FILE_SIZE` — the file is empty, or larger than 10 MB.
* `INVALID_ROW_COUNT` — the file has zero data rows, or more than 10,000.
* `COLUMN_COUNT_MISMATCH` — a data row has a different field count than the header row (typically caused by an unescaped comma inside a field value). `rowNumber` identifies the offending row.
* `UNREADABLE_FILE` — the file could not be parsed for another reason (malformed CSV, I/O read failure).

Example:

```json
{
  "numValidRows": 0,
  "rowErrors": [],
  "fileError": {
    "type": "COLUMN_COUNT_MISMATCH",
    "rowNumber": 3,
    "description": "Row 3 has a different number of columns than the header row."
  }
}
```

***

### Deprecated endpoints

> **These endpoints are retained for backwards compatibility. Migrate callers to `/upsert` and `/validate-upsert`.**

#### `POST /matters-csv/upload-csv` (deprecated)

> Use `POST /matters-csv/upsert` instead for idempotent behavior. This endpoint silently skips rows that already exist in the review cycle.

Creates a new matter entry for each row in the CSV. Rejects all rows if any single row contains errors.

* **Content-Type:** `multipart/form-data`
* **Query parameter:** `reviewCycleId` (UUID, required) — the review cycle to associate the uploaded matters with.
* **Form parts:** `file` (required) — CSV file, format as above.

**Behavioral differences from `/upsert`:**

* Rows whose full identifier `(Matter ID, Matter Name, Attorney Email, Billing Attorney Email, Client Name)` matches an existing matter entry on the target review cycle are silently skipped. Rows differing on any of those five fields are treated as new entries.
* Exact-duplicate rows within one file are silently collapsed to one entry. Rows sharing `(Matter ID, Client Name, Attorney Email)` but differing on non-key fields are not detected at the CSV validation layer.
* Cross-row check across the file is limited to `Matter Name` — rows sharing a `Matter ID` must agree on `Matter Name` or the file is rejected. `Description` and `Client Name` are not compared across rows.
* No response body on success.

**Responses:**

* `200 OK` — matters created successfully (no response body).
* `400 Bad Request` — invalid CSV file format, or CSV file contains one or more invalid rows. Use `/validate-csv` for row-level error details.
* `401 Unauthorized` / `403 Forbidden` — see Authentication.
* `404 Not Found` — review cycle not found.

#### `POST /matters-csv/validate-csv` (deprecated)

> Use `POST /matters-csv/validate-upsert` instead.

Pre-flight-validates a matter entry CSV for the `/upload-csv` endpoint. Does not persist any data.

**Validation rules differ from `/validate-upsert`:** this endpoint validates against the `/upload-csv` rules only — it does not flag duplicate upsert keys within a file, and it does not check that rows sharing `(Matter ID, Client Name)` agree on `Description`. Do not use this endpoint as a pre-flight for `/upsert`.

**Request** — same shape as `/validate-upsert`.

**Responses** — same shape as `/validate-upsert`.

***

### Authentication

All four endpoints require an authenticated Perform administrator. Unauthenticated requests return `401 Unauthorized`; authenticated non-admin callers return `403 Forbidden`.
