# Flo API Documentation

Welcome to the Flo API! This guide explains how clients can use Flo's API to programmatically sync data from Flo into their other systems or vice versa. Flo's API offerings unlock five key use cases.

<table><thead><tr><th width="450.49053955078125">Use Case</th><th>Recruiting Products</th><th>Performance Management Products</th></tr></thead><tbody><tr><td>Syncing candidate and job application data from Flo into an HRIS system</td><td>X</td><td></td></tr><tr><td>Syncing candidate and job application data from Flo into an external reporting system</td><td>X</td><td></td></tr><tr><td>Syncing public job application details including open and close dates and job description to a custom Careers page or external job board like LinkedIn </td><td>X</td><td></td></tr><tr><td>Importing matter/billing data to keep matter/billing data current in Flo for performance reviewer selections</td><td></td><td>X</td></tr><tr><td>Importing user data to keep employee access and employee details current in Flo</td><td>X</td><td>X</td></tr></tbody></table>

The following documentation outlines authentication, schema, examples, and a proposed solution design. Customers can use this API to retrieve the data that will be directly imported into their HRIS system.

We plan to continue expanding our API to solve a variety of use cases based on customer feedback. Please feel free to provide us with any feedback you may have.


# Authentication

## Authentication

For the initial launch of our API, we are relying on JWT Authentication to provide security for your requests. This method is what we use today to power our web application log in.&#x20;

Basic path for authentication will require an existing admin to create a new user for the api.

### How to Create an Admin Service Account for Public API Access

This guide explains how to create a new Admin account in Flo specifically for programmatic API access (a "service account"). This account is used by software systems or scripts to authenticate with the Flo API.

You must be a current Flo Admin to perform these steps.

#### Part 1: Prerequisites

Before you begin, you must have a dedicated email address ready for this new service account (e.g., <api.user@yourcompany.com>). You will need access to this email account's inbox to complete the setup.

#### Part 2: Create New Admin Member & Determine Auth Path

1. From your Admin account, click Members in the main navigation bar on the left side of the screen.

![](/files/8yRDJnfzB0BP8RwbNNLI)

2. On the Members list page, click the "Create New Member" button (this is typically in the top right corner).
3. A new window/modal will appear. Fill in the information for the new service account member:
   1. Admin Status: Select “Admin” for Admin Status
   2. Login type: If your organization has MFA enabled, select MFA
      1. Note: If your organization does not have MFA or SSO enabled you will not see these options and will log in using email and password.
   3. First Name: Recommend using “API”
   4. Last Name: Recommend using “User”
   5. Email: The dedicated email address you prepared.\
      ![](/files/Mhv8IzWJWZFsNgDQJa2M)
4. Determine Your Authentication Path:
   1. When you check "Admin," a new radio group called “Login type” may appear asking if this user requires extra security (SSO or MFA) to log in.
   2. If you see this option OR you know your org requires MFA for admins: You are an MFA-Enabled Org. You must select the MFA option (if presented). After clicking "Send Invite," you will follow Part 3, Path B.
   3. If you do NOT see this option AND you know your org does not use MFA: You are a Non-MFA Org. After clicking "Create Member," you will follow Part 3, Path A.
   4. If you are unsure: Please contact your Customer Success representative to confirm your org's admin authentication settings before proceeding.
5. Click “Create Member” and an email will be sent to finish the new account creation (setup from email still needed in following steps).

#### Part 3: Finalize Account Setup

Follow the instructions that match the path you determined in Part 2.

**Path A: For non-MFA Orgs**

1. Go to the inbox for the new service account's email address.
2. Open the invitation email, subject: “New Flo Account for You” from Flo and click the activation link, “Click here to create your account”.
3. A web page will open in your default browser, prompting you to create a password.
4. Create a strong, unique password for this account.
   1. Flo recommends using a password manager (like 1Password or LastPass) to generate and store a password.
   2. If creating one manually, ensure it is long (16+ characters) and complex (using uppercase, lowercase, numbers, and symbols).
   3. Store this password securely. You will need it for your API configuration.
5. Your setup is complete. This account is now active. Proceed to Part 4: Securely Store Credentials.

<br>

**Path B: For MFA-Enabled Orgs**

This path requires extra steps to retrieve the MFA secret key for programmatic use.

1. Follow Path A steps 1-4 to set up a new password for this account.
2. After setting the password, you will be logged in.  Log out and navigate to your organization’s login page.  If your organization has SSO enabled, you will need to enter this URL in the browser to access the normal login form:
   1. <https://florecruit.com/app/your\\_organization\\_name/adminLoginMfaForSsoOrg/&#x20>;
3. Click the "Forgot password?" link.\
   ![](/files/PvnjDjBopQaSkbabExbc)
4. Enter the service account's email address and submit the form.![](/files/trQobvJDgo009ocW3zVC)
5. Go back to the service account's email inbox. Open the new "Forgot Password Account Recovery" email and click the link.
6. You will be prompted to create a new password again. Enter a new, secure password.
7. Once you submit this new password, the page will load the MFA Setup screen showing a QR code.
8. Below the QR code, click the link "show secret key for manual configuration".![](/files/3QsfzxsJ29wZ1eWp1d3b)
9. A text string will appear. Click the copy button to copy this MFA Secret Key to your clipboard.  Store this secret key somewhere safe, Part 4 will go over how to use this secret key.![](/files/PXdzmCgFdifOAIbs220B)
10. Finish the MFA setup by scanning the QR code with an MFA application.  Enter the 6 digit code in the form field “MFA Code”.
11. You should now be logged in to the system.  Log out.
12. Your setup is complete. Proceed to Part 4: Securely Store Credentials.

#### Part 4: Securely Store Credentials

You must securely store these credentials for your application to use.

* Credentials from Part 3 should never be stored in an unsecure location.  Do not email or share your credentials with anyone.
* Programmatic access will require storing credentials in the environment where your code will be running to access the API.  It is recommended that you use a .env file or store these credentials as environment variables and load them when running your application. If going .env route we recommend it to your gitignore file and not checking it into git.
* For Non-MFA Orgs: You need the Email and Password.
* For MFA-Enabled Orgs: You need the Email, Password, and the MFA Secret Key you just copied.

#### Part 5: Next Steps

Your admin service account is now fully configured.

We suggest that you authenticate prior to each API request. That will involve you making a POST request with your credentials to our Login Endpoint.\
\
The Login Endpoint is&#x20;

[https://florecruit.com/app/{org\_name}/admin/auth/](https://florecruit.com/app/%7Bcreds\['site_name']%7D/admin/auth/)

The endpoint always returns a 200 OK status response. If the Authentication was successful, it will also contain a cookie containing an authorization token.&#x20;

To verify authentication succeeded, make a test API call after authenticating. If the test call returns 401 Unauthorized, authentication failed—check your credentials and ensure you've included mfaCode if your account requires MFA.

Refer to the “Python Code Example” and “[Node.js](http://node.js) Code Example” sections at the end of this document for more specifics about usage.&#x20;

#### Example Setup Using Postman

The following screenshots walk you through setting up Postman to successfully authenticate with the Flo API.

Postman Example showing how to set the Auth Cookie

<figure><img src="/files/VpwVXVgEddG3DT3W9KMe" alt=""><figcaption></figcaption></figure>

Postman Example how to set the resume as part of the body of the request.

![](/files/yCOvPw8btEQaDW834GnO)<br>


# HRIS API


# Overview

Welcome to the Flo HRIS API! This guide explains how customers can programmatically sync candidate and job application data from Flo into their HRIS platforms. It outlines authentication, schema, examples, and the proposed solution design. Customers can use this API to retrieve the data that will be directly imported into their HRIS system.

### GET /v1/job-applications

Filtering via Query Parameters

**job\_application\_status** (string, optional) – filters by pipeline stage (Application Extended, Offer Accepted, etc.)

* This field is a set of custom statuses defined by each customer
* The value needs to be a step in an Active Pipeline Template
* The value is case insensitive
* When included, only Statuses of that value are returned.
* When omitted, all Statuses within the timeframe are returned.
* Use Case Examples
  * Viewing data on job applications in the “Hired” Status
  * Viewing only job applications that have “Offer Rejected”

**job\_application\_status\_bucket** (string, optional) – filters by pipeline status bucket (Applied, Offered, Hired etc.)

* This represents the group of statues steps used by your organization
* The value needs to correspond to a Bucket in the pipelines you use for jobs
* The value is case insensitive (i.e. “HIRED” and “hired” are treated the same)
* When included, all of the statuses with the corresponding “statusBucketName” are included in the response.
* Use Case Examples
  * Viewing all job applications that have been in the “Offered” Status bucket

**This Flo article can help with more concepts around Pipeline Building:** [**https://help.florecruit.com/en/articles/6552344-ats-creating-and-editing-pipeline-templates**](https://help.florecruit.com/en/articles/6552344-ats-creating-and-editing-pipeline-templates)

**job\_application\_status\_start** (datetime with timezone, optional) – Returns job applications that had at least one status change within the specified date range. The jobApplicationStatuses array shows only status changes within that range.

Note: jobApplicationLastStatusUpdate always shows the candidate's most recent status timestamp, which may be outside your query range.

* Default value is 30 days in the past
* Not including a time, will default the start time to midnight of the date provided

**job\_application\_status\_end** (datetime with timezone, optional) – filter candidates by last status update.

* Not including a time will set the date to midnight of the date provided

**Note on Timezones:**

* We store DateTime data in the UTC timezone
* For the parameters above, you have the following options:
  * If you include your local timezone, we will parse that and convert to UTC time.
  * If you include a DateTime with no timezone, we will assume it is UTC time.
  * If you include a Date with no time, we will default to midnight UTC of that date.

Using these query parameters if you wanted all job applications that were in a “Hired” status in October of 2025, you would request like this:

```shellscript
GET 
/v1/job-applications?job_application_status_start=2025-10-01&job_application_status_end=2025-11-01&job_application_status=Hired
```

**Response Data Schema**

#### Candidate Fields

| firstName    | string         | Candidate’s first name                                         |
| ------------ | -------------- | -------------------------------------------------------------- |
| lastName     | string         | Candidate’s last name                                          |
| email        | string \| null | If the user is not created with an email, value will be null   |
| phone        | string \| null | Candidate phone number                                         |
| candidateId  | UUID string    | Unique ID per candidate                                        |
| candidateUrl | string         | Flo URL that navigates to the Candidate's Profile in your ATS. |

#### Job / Job Application Fields

| applicationId                  | UUID string              | Unique ID per job application                                                                                   |
| ------------------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| jobTitle                       | string                   | Title of the job                                                                                                |
| jobId                          | UUID string              | Unique ID per job                                                                                               |
| department                     | string                   | Department from job record                                                                                      |
| officeLocation                 | string                   | Office location from job record                                                                                 |
| employmentType                 | string                   | Employment type (Full-time, Intern, etc.)                                                                       |
| hiringType                     | string                   | Hiring type (Lateral, Student Hiring, etc.)                                                                     |
| jobApplicationLastStatusUpdate | datetime                 | Timestamp of the latest job application status change – note that this can be later than your filtered end time |
| position                       | string                   | Job position type                                                                                               |
| requisitionId                  | string \| null           | Job requisition id                                                                                              |
| startDate                      | datetime \| null         | Offer Details Start Date                                                                                        |
| salary                         | int \| null              | Offer Details Salary                                                                                            |
| addressLine1                   | string                   | Candidate address per job app                                                                                   |
| addressLine2                   | string                   | Candidate address per job app                                                                                   |
| city                           | string                   | Candidate address per job app                                                                                   |
| state                          | string                   | Candidate address per job app                                                                                   |
| zip                            | string                   | Candidate address per job app                                                                                   |
| jobApplicationStatuses         | See Below (List of JSON) | List of job application status changes                                                                          |

#### Job Application Status Fields

| statusName       | string   | Name of status                              |
| ---------------- | -------- | ------------------------------------------- |
| statusBucketName | string   | Name of status bucket                       |
| statusUpdatedAt  | datetime | Time the job application entered the status |

#### Job Application Custom Fields

| customFieldTitle  | string                   | The title/question text of the custom field                                                                                                                                                                                                                                                                          |
| ----------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| customFieldAnswer | array of strings \| null | <p>Array of answer values.</p><ul><li>For single-select, free text, date, numerical, monetary, and boolean questions: contains a single value</li><li>For multi-select questions: contains multiple values</li><li>If no answer exists for the question: <code>null</code> or empty array <code>\[]</code></li></ul> |

#### JSON Format:

<pre class="language-json"><code class="lang-json">[
  {
    "firstName": "Jane",
    "lastName": "Doe",
    "candidateId": "fge25ghn-4mh7-4kli-c9sz-e436ht34jt09",
    "candidateUrl": "https://florecruit.com/app/{org_name}/admin/recruits/125342/",
    "email": "jane.doe@example.com",
    "phone": "+1-555-123-4567",
    "applicationId": "f18fc41a-4357-4520-a9ce-e477ef947b36",
    "jobTitle": "Entry Level Associate",
    "jobId": "d12dvq4p-6234-eo23-a0o3-d41fef91edb12b",
    "department": "Litigation",
    "officeLocation": "New York",
    "employmentType": "Full-time",
    "hiringType": "Student Hiring",
    "position": "Associate",
    "requisitionId": "req-12345",
    "startDate": "2025-10-10",
    "salary": 175000,
    "addressLine1": "1428 Elm Street",
    "addressLine2": "Apartment 101",
    "city": "Springfield",
    "state": "Illinois",
    "zipCode": "62704",
    "jobApplicationStatuses": [
      {
        "statusName": "Application Received",
<strong>        "statusBucketName": "Applied",
</strong>        "statusUpdatedAt": "2025-10-01T22:07:44Z"
      },
      {
        "statusName": "Interviewing",
        "statusBucketName": "Interviewing",
        "statusUpdatedAt": "2025-10-03T12:07:44Z"
      },
      {
        "statusName": "Hired",
        "statusBucketName": "Hired",
        "statusUpdatedAt": "2025-10-06T22:07:44Z"
      }
    ],
    "jobApplicationLastStatusUpdate": "2025-10-06T22:07:44Z",
    "customFields": [
      {
        "customFieldTitle": "Years of Experience",
        "customFieldAnswer": ["5"]
      },
      {
        "customFieldTitle": "Preferred Practice Areas",
        "customFieldAnswer": ["Litigation", "Corporate Law", "Employment Law"]
      },
      {
        "customFieldTitle": "Bar Admission Date",
        "customFieldAnswer": ["2020-06-15"]
      },
      {
        "customFieldTitle": "Additional Notes",
        "customFieldAnswer": null
      }
    ]
  },
]
</code></pre>

### Filtering Examples with Sample Data

If you would like to get the Job Applications that were moved to the “Hired” Status on October 6th, 2025 – you would structure your request like this:

```shellscript
GET 
/v1/job-applications?job_application_status_start=2025-10-06T00:00:00Z&job_application_status_end=2025-10-07T00:00:00Z&job_application_status=Hired
```

\
The result would be the following. For simplicity, assume there is only one result in the response.

```json
[
  {
    "firstName": "Jane",
    "lastName": "Doe",
    "candidateId": "fge25ghn-4mh7-4kli-c9sz-e436ht34jt09",
    "candidateUrl": "https://florecruit.com/app/{org_name}/admin/recruits/125342/",
    "email": "jane.doe@example.com",
    "phone": "+1-555-123-4567",
    "applicationId": "f18fc41a-4357-4520-a9ce-e477ef947b36",
    "jobTitle": "Entry Level Associate",
    "jobId": "d12dvq4p-6234-eo23-a0o3-d41fef91edb12b",
    "department": "Litigation",
    "officeLocation": "New York",
    "employmentType": "Full-time",
    "hiringType": "Student Hiring",
    "position": "Associate",
    "requisitionId": "req-12345",
    "startDate": "2025-10-10",
    "salary": 175000,
    "addressLine1": "1428 Elm Street",
    "addressLine2": "Apartment 101",
    "city": "Springfield",
    "state": "Illinois",
    "zipCode": "62704",
    "jobApplicationStatuses": [
      {
        "statusName": "Hired",
        "statusBucketName": "Hired",
        "statusUpdatedAt": "2025-10-06T22:07:44Z"
      }
    ],
    "jobApplicationLastStatusUpdate": "2025-10-06T22:07:44Z",
    "customFields": [
      {
        "customFieldTitle": "Years of Experience",
        "customFieldAnswer": ["5"]
      },
      {
        "customFieldTitle": "Preferred Practice Areas",
        "customFieldAnswer": ["Litigation", "Corporate Law", "Employment Law"]
      },
      {
      "customFieldTitle": "Bar Admission Date",
      "customFieldAnswer": ["2020-06-15"]
      },
      {
        "customFieldTitle": "Additional Notes",
        "customFieldAnswer": null
      }
    ]
  },
]
```

**The same request with a Status of “Interviewing”**

```shellscript
GET 
/v1/job-applications?job_application_status_start=2025-10-06T00:00:00Z&job_application_status_end=2025-10-07T00:00:00Z&job_application_status=Interviewing
```

**Would return**

```json
[]
```

Because there is no Status Update for “Interviewing” within that time frame.

Filtering by the Status Bucket

```shellscript
GET 
/v1/job-applications?job_application_status_start=2025-10-06T00:00:00Z&job_application_status_end=2025-10-07T00:00:00Z&job_application_status_bucket=Applied
```

Would return only the statuses in that bucket

```json
[
  {
    "firstName": "Jane",
    "lastName": "Doe",
    "candidateId": "fge25ghn-4mh7-4kli-c9sz-e436ht34jt09",
    "candidateUrl": "https://florecruit.com/app/{org_name}/admin/recruits/125342/",
    "email": "jane.doe@example.com",
    "phone": "+1-555-123-4567",
    "applicationId": "f18fc41a-4357-4520-a9ce-e477ef947b36",
    "jobTitle": "Entry Level Associate",
    "jobId": "d12dvq4p-6234-eo23-a0o3-d41fef91edb12b",
    "department": "Litigation",
    "officeLocation": "New York",
    "employmentType": "Full-time",
    "hiringType": "Student Hiring",
    "position": "Associate",
    "requisitionId": "req-12345",
    "startDate": "2025-10-10",
    "salary": 175000,
    "addressLine1": "1428 Elm Street",
    "addressLine2": "Apartment 101",
    "city": "Springfield",
    "state": "Illinois",
    "zipCode": "62704",
    "jobApplicationStatuses": [
      {
        "statusName": "Application Received",
        "statusBucketName": "Applied",
        "statusUpdatedAt": "2025-10-01T22:07:44Z"
      },
    ],
    "jobApplicationLastStatusUpdate": "2025-10-06T22:07:44Z",
    "customFields": [
      {
        "customFieldTitle": "Years of Experience",
        "customFieldAnswer": ["5"]
      },
      {
        "customFieldTitle": "Preferred Practice Areas",
        "customFieldAnswer": ["Litigation", "Corporate Law", "Employment Law"]
      },
      {
        "customFieldTitle": "Bar Admission Date",
        "customFieldAnswer": ["2020-06-15"]
      },
      {
        "customFieldTitle": "Additional Notes",
        "customFieldAnswer": null
      }
    ]
  },
]
```

### Understanding Custom Fields

Custom Fields are custom questions that your organization can configure to collect additional information on job applications. These fields are returned in the custom`Fields` array for each job application.

**Key Points:**

1. **Visibility:** Only custom fields configured with `SHOW_ON_JOB_APPLICATION = TRUE` are included in the API response
   1. If no Custom Fields are configured the customFields field will be not be present in the response.
2. **Answer Types:** Custom fields support various question types:
   * **Free Text:** Text responses
   * **Date:** Date values (returned as string in ISO format)
   * **Numerical:** Numeric values (returned as string)
   * **Monetary**: Currency values (returned as string)
   * **Single-Select:** One option selected from a list
   * **Multi-Select:** Multiple options selected from a list
3. **Answer Format:** All answers are returned as strings in the `customFieldAnswer` array:
   * Single-value questions (free text, date, numerical, monetary, single-select, boolean): array contains one string
   * Multi-select questions: array contains multiple strings
   * Unanswered questions: `null`
4. **Consistency:** All custom fields configured for job applications are returned, even if they have no answer

**Example Scenarios (Responses have been abbreviated to focus on Custom Fields):**

**Scenario 1:** Job Application with Multiple Custom Fields

```json
{
  "applicationId": "f18fc41a-4357-4520-a9ce-e477ef947b36",
  "firstName": "Jane",
  "lastName": "Doe",
  "customFields": [
    {
      "customFieldTitle": "Years of Experience",
      "customFieldAnswer": ["8"]
    },
    {
      "customFieldTitle": "Practice Areas of Interest",
      "customFieldAnswer": ["Litigation", "Corporate Law", "Employment Law"]
    },
    {
      "customFieldTitle": "Bar Admission Date",
      "customFieldAnswer": ["2020-06-15"]
    },
    {
      "customFieldTitle": "Willing to Relocate",
      "customFieldAnswer": ["true"]
    }
  ]
}
```

**Scenario 2:** Job Application with Unanswered Custom Fields

```json
{
  "applicationId": "a1b2c3d4-5678-90ef-ghij-klmnopqrstuv",
  "firstName": "John",
  "lastName": "Smith",
  "customFields": [
    {
      "customFieldTitle": "Years of Experience",
      "customFieldAnswer": ["3"]
    },
    {
      "customFieldTitle": "Additional Notes",
      "customFieldAnswer": null
    },
    {
      "customFieldTitle": "Preferred Start Date",
      "customFieldAnswer": null
    }
  ]
}
```

**Note:** Custom fields are organization-specific. The fields returned will match the custom field questions configured in your Flo organization settings.


# API Response Codes

**200 - Success**

Returned when the request is valid and the authenticated admin has access to this api endpoint. Response body is a JSON array of job applications formatted for HRIS.

**400 - Bad Request**

The request could not be processed due to invalid input. Common causes:

* Incorrect date format
* \`job\_application\_status\_start\` or \`job\_application\_status\_end\` is not a valid date/datetime.
* Expected formats:
  * \`yyyy-MM-dd\` or \`yyyy-MM-ddTHH:mm:ss\` or \`
* Example message:
  * “Invalid date format: 'Tuesday'. Expected date or datetime in one of the following ISO-8601 formats: " + "'yyyy-MM-dd', 'yyyy-MM-dd'T'HH:mm:ss', or 'yyyy-MM-dd'T'HH:mm:ssZ' (with timezone or UTC offset).”
* Invalid date range
  * \`job\_application\_status\_start\` is after \`job\_application\_status\_end\`.
  * Example message: “job\_application\_status\_start date must be before the job\_application\_status\_end”
* Invalid status filter
  * \`job\_application\_status\` is not a recognized pipeline status for your jobs.
  * The status needs exist on an active Pipeline
  * Example message: “'INVALID\_STATUS' is not a valid active status value.'”
* Invalid status bucket filter
  * \`job\_application\_status\_bucket\` is not a recognized pipeline status bucket for your jobs.
  * The status needs exist on an active Pipeline
  * Example message: “'INVALID\_STATUS' is not a valid status bucket value.'”

Fix this status code by correcting parameter formats/values and retrying.

**401 - Unauthorized**

Authentication or authorization failed. Typical reasons:

* The HRIS API feature is not enabled for your organization.
* You are attempting to use the API with credentials tied to a non-admin user
* Example message:
  * “Reach out to support for access to this endpoint”

Remediation: Contact Flo Support to enable access and ensure your account has the necessary permissions.

**429 - Too Many Requests**

The request was rate-limited. Our API is Rate Limited to 1000 requests / 5 minutes.Remediation: Back off and retry after the time specified in response headers (if present), or implement client-side rate limiting/retries with exponential backoff.

**500 - Internal Server Error**

An unexpected error occurred on the server while processing the request.

* Example message:
  * “Failed to fetch candidates”

Remediation: Retry later. If the error persists, contact Flo Support with the request details and timestamp.<br>


# Code Examples

## Python Code Example

Non-MFA Orgs:

```python
import os
import requests
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Access the environment variables
EMAIL = os.getenv("EMAIL")
PASS = os.getenv("PASS")
SITE = os.getenv("SITE")

creds = dict(
    site_name=SITE,
    username=EMAIL,
    password=PASS
)

session = requests.Session()
response = session.post(
    url=f"https://florecruit.com/app/{creds['site_name']}/admin/auth/",
    data={
        "email": creds['username'],
        "password": creds['password']
    },
    allow_redirects=False
)

api_response = session.get(
    "https://api.florecruit.com/v1/job-applications",
    params={
        "job_application_status_start": "2025-08-01T00:00:00",
        "job_application_status_end": "2025-10-31T23:59:59",
        "job_application_status": "Hired Status"
    },
    headers={
       "Accept": "application/json"
        }
)

print(f"\nAPI status: {api_response.status_code}")
if api_response.status_code == 200:
    print("✓ Authentication successful")
    print(f"Results: {len(api_response.json())} records")
    print(json.dumps(api_response.json(), indent=2))
elif api_response.status_code == 401:
    print("✗ Authentication failed")
else:
    print(f"Unexpected response: {api_response.text}")
```

MFA Orgs

– Add the MFA\_SECRET Key from the authentication setup to the .env file so it can be used to generate passcodes that will be included with the Login request

```python
import os
import requests
import pyotp
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Access the environment variables
EMAIL = os.getenv("EMAIL")
PASS = os.getenv("PASS")
SITE = os.getenv("SITE")
MFA_SECRET_KEY = os.getenv("MFA_SECRET_KEY")

creds = dict(
    site_name=SITE,
    username=EMAIL,
    password=PASS,
    totp=pyotp.TOTP(MFA_SECRET_KEY),
)

session = requests.Session()
response = session.post(
    url=f"https://florecruit.com/app/{creds['site_name']}/admin/auth/",
    data={
        "email": creds['username'],
        "password": creds['password'],
	 "mfaCode": creds['totp'].now()
    },
    allow_redirects=False
)

api_response = session.get(
    "https://api.florecruit.com/v1/job-applications",
    params={
        "job_application_status_start": "2025-08-01T00:00:00",
        "job_application_status_end": "2025-10-31T23:59:59",
        "job_application_status": "Hired Status"
    },
    headers={
       "Accept": "application/json"
        }
)

print(f"\nAPI status: {api_response.status_code}")
if api_response.status_code == 200:
    print("✓ Authentication successful")
    print(f"Results: {len(api_response.json())} records")
    print(json.dumps(api_response.json(), indent=2))
elif api_response.status_code == 401:
    print("✗ Authentication failed")
else:
    print(f"Unexpected response: {api_response.text}")

```

\
Node.js Code Example
--------------------

Non-MFA Org

```javascript
const axios = require("axios");
const dotenv = require("dotenv");

// Load environment variables from .env file
dotenv.config();
// Access the environment variables
const EMAIL = process.env.EMAIL;
const PASS = process.env.PASS;
const SITE = process.env.SITE;

// Credentials
const creds = {
 site_name: SITE,
 username: EMAIL,
 password: PASS,
};

const BASE_URL = `https://florecruit.com/app/${creds.site_name}/admin/auth/`;

const main = async () => {
 try {
   // Create an axios instance to keep cookies/session
   const session = axios.create({
     withCredentials: true,
     maxRedirects: 0,
     validateStatus: status => status >= 200 && status < 400, // accept 302
     timeout: 30000 // 30 second timeout
   });

   // Login request
   await session.post(BASE_URL, {
     email: creds.username,
     password: creds.password,
   }, {
     headers: {
       'Content-Type': 'application/json'
     }
   });

   // API request after login
   const response = await session.get(
     "https://api.florecruit.com/v1/job-applications",
     {
       params: {
         job_application_status_start: "2025-10-01T00:00:00",
         job_application_status_end: "2025-10-31T23:59:59",
         job_application_status: "Hired"
       },
       headers: {
         Accept: "application/json"
       }
     }
   );

   console.log(JSON.stringify(response.data, null, 2));
 } catch (err) {
   console.error(err.response?.data || err.message);
 }
};


main();

```

**MFA Orgs**

– Add the MFA\_SECRET Key from the authentication setup to the .env file so it can be used to generate passcodes that will be included with the Login request

```javascript
const axios = require("axios");
const dotenv = require("dotenv");
const { authenticator } = require("otplib");


// Load environment variables from .env file
dotenv.config();
// Access the environment variables
const EMAIL = process.env.EMAIL;
const PASS = process.env.PASS;
const SITE = process.env.SITE;
const SECRETMFACODE = process.env.SECRETMFACODE;


// Credentials
const creds = {
 site_name: SITE,
 username: EMAIL,
 password: PASS,
 totp: authenticator.generate(SECRETMFACODE)
};


const BASE_URL = `https://florecruit.com/app/${creds.site_name}/admin/auth/`;


const main = async () => {
 try {
   // Create an axios instance to keep cookies/session
   const session = axios.create({
     withCredentials: true,
     maxRedirects: 0, // IMPORTANT: Do not follow redirects
     validateStatus: status => status >= 200 && status < 400, // accept 302
     timeout: 30000 // 30 second timeout
   });


   // Login request
   const loginResponse = await session.post(BASE_URL, {
     email: creds.username,
     password: creds.password,
     mfaCode: creds.totp
   }, {
     headers: {
       'Content-Type': 'application/json'
     }
   });


   const setCookieHeader = loginResponse.headers['set-cookie'];
   let floAuthCookie = null;


   if (setCookieHeader) {
     const floAuthCookieString = setCookieHeader.find(cookie => cookie.startsWith('FLOAUTH='));
     if (floAuthCookieString) {
       floAuthCookie = floAuthCookieString.split(';')[0];
     }
   }


   const headers = {
     Accept: "application/json"
   };


   if (floAuthCookie) {
     headers.Cookie = floAuthCookie;
   }


   // API request after login
   const response = await session.get(
     "https://api.florecruit.com/v1/job-applications",
     {
       params: {
         job_application_status_start: "2025-10-01T00:00:00",
         job_application_status_end: "2025-10-31T23:59:59",
         job_application_status: "Hired"
       },
       headers: headers
     }
   );


   console.log(JSON.stringify(response.data, null, 2));
 } catch (err) {
   console.error(err.response?.data || err.message);
 }
};


main();
```

\ <br>


# Public Jobs API

The following API calls allow you to retrieve your list of open jobs from Flo's ATS platform including the job details and how to apply to those jobs. Common client use cases for this API include creating custom careers pages or pulling open jobs into reports.

### API Calls for Retrieving Public Jobs from Flo ATS <a href="#h_b67e09969e" id="h_b67e09969e"></a>

### Retrieve list of jobs <a href="#h_a30c9298af" id="h_a30c9298af"></a>

| Method | Endpoint                                                                | Description                                                                                                                                                                                                                                          |
| ------ | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | <https://florecruit.com/api/v2/public-jobs/[org-name]/career-page-jobs> | <p>Retrieve list of public jobs</p><p>Note: org-name is your friendly organization name in Flo platform</p><p>Note: the response is an array of jobs. The “extension” field is the unique ID of the job that you can use to get the job details.</p> |

**Sample Request/Response (an array):**

Request

<https://florecruit.com/api/v2/public-jobs/florecruit/career-page-jobs>

Response

```
[
   {
     "id": 14964,
     "extension": "MTQ5NjR-RkpwcUZOTDRibE1IeEtWcWRleUlFM0ZwelFHeXVx",
     "title": "Full Stack Software Engineer II",
     "openDate": "2024-09-10T00:01:00",
     "closeDate": null,
     "openTimezone": "America/Chicago",
     "closeTimezone": "America/Chicago",
     "isPublic": true,
"jobEmploymentTypes": [
       {
         "employmentType": "Full-Time",
         "id": 22237,
         "jobId": 14964,
         "employmentTypeId": 37
       }
     ],
     "jobOffices": [
       {
         "name": "Austin, Texas",
         "id": 14137,
         "officeId": 967,
         "jobId": 14964
       }
     ],
     "hiringLeads": [
       {
         "id": 88470,
         "email": "christina@florecruit.com",
         "fname": "Christina",
         "lname": "Wells"
       }
     ],
     "positionTypes": [
       {
         "name": "Mid Level",
         "id": 22237,
         "jobId": 14964,
         "positionTypeId": 33
       }
     ],
     "departments": [
       {
         "name": "Engineering",
         "id": 22238,
         "jobId": 14964,
         "departmentId": 45
       }
     ],
     "description": ""description": "\u003Ch3\u003E\u003Cstrong\u003EWho We Are\u003C/strong\u003E\u003C/h3\u003E\u003Cp\u003E\u003C/p\u003E\u003Cp\u003EFlo Recruit is a venture-backed, high-growth SaaS company transforming the legal talent industry. Our solutions connect law firms, law schools, and attorneys to create a powerful talent ecosystem."
   },
   "applyUrl": "https://florecruit.com/v2/app/florecruit/jobs/MjEzMjB-Z3NNdzFDajg4Tkd6ZDNqb2hkZmlpYzIxUmZzRGNi/apply"
   {
"id": 14965,
     "extension": "MTQ5NjV-ZHZ2Nkh5dDlVTlpFWjJJa2l1dlU0RkxWYllmOGho",
     "title": "Senior Full Stack Software Engineer",
     "openDate": "2024-09-10T00:01:00",
     "closeDate": null,
     "openTimezone": "America/Chicago",
     "closeTimezone": "America/Chicago",
     "isPublic": true,
     "jobEmploymentTypes": [
       {
         "employmentType": "Full-Time",
         "id": 22238,
         "jobId": 14965,
         "employmentTypeId": 37
       }
     ],
     "jobOffices": [
       {
         "name": "Austin, Texas",
         "id": 14138,
         "officeId": 967,
         "jobId": 14965
       }
     ],
     "hiringLeads": [
       {
         "id": 88470,
         "email": "christina@florecruit.com",
         "fname": "Christina",
         "lname": "Wells"
       }
     ],
     "positionTypes": [
       {
         "name": "Senior Level",
         "id": 22238,
         "jobId": 14965,
         "positionTypeId": 34
       }
     ],
     "departments": [
       {
"name": "Engineering",
         "id": 22239,
         "jobId": 14965,
         "departmentId": 45
       }
     ],
     "description": "As we continue to grow, we’re looking for a Front-End Engineer with strong React expertise and a sharp design sense to help us deliver beautiful, high-performing user experiences.",
     "applyUrl": "https://florecruit.com/v2/app/florecruit/jobs/MjE3MDJ-TFVHVU5kcjRnYnZnemJzY0prYzFmOFU4ZGlJVVFj/apply"
   },
   {
     "id": 17091,
     "extension": "MTcwOTF-dkUyTGtGSm9BcHJwejBxc3BvWFhHRXJaOGhsYVIw",
     "title": "Product Designer",
     "openDate": "2025-02-21T00:01:00",
     "closeDate": null,
     "openTimezone": "America/Chicago",
     "closeTimezone": "America/Chicago",
     "isPublic": true,
     "jobEmploymentTypes": [
       {
         "employmentType": "Full-Time",
         "id": 26467,
         "jobId": 17091,
         "employmentTypeId": 37
       }
     ],
     "jobOffices": [
       {
         "name": "Austin, Texas",
         "id": 16480,
         "officeId": 967,
         "jobId": 17091
       }
     ],
     "hiringLeads": [
       {
         "id": 88470,
         "email": "christina@florecruit.com",
         "fname": "Christina",
         "lname": "Wells"
       }
     ],
     "positionTypes": [
       {
         "name": "Mid Level",
         "id": 26467,
         "jobId": 17091,
         "positionTypeId": 33
       }
     ],
     "departments": [
       {
         "name": "Product",
         "id": 26468,
         "jobId": 17091,
         "departmentId": 46
       }
     ],
     "description": null,
     "applyUrl": null
   },
   {
     "id": 18422,
     "extension": "MTg0MjJ-RFRvNDB1N3JWeEloSnhTcG9yTHNEU3dVM25vdnpX",
     "title": "Account Manager",
     "openDate": "2025-04-03T00:01:00",
     "closeDate": null,
     "openTimezone": "America/Chicago",
     "closeTimezone": "America/Chicago",
     "isPublic": true,
     "jobEmploymentTypes": [
       {
         "employmentType": "Full-Time",
         "id": 28938,
         "jobId": 18422,
         "employmentTypeId": 37
       }
     ],
     "jobOffices": [
       {
         "name": "Austin, Texas",
         "id": 18884,
         "officeId": 967,
         "jobId": 18422
       }
     ],
     "hiringLeads": [
       {
 "id": 88470,
         "email": "christina@florecruit.com",
         "fname": "Christina",
         "lname": "Wells"
       }
     ],
     "positionTypes": [
       {
         "name": "Mid Level",
         "id": 28938,
         "jobId": 18422,
         "positionTypeId": 33
       }
     ],
     "departments": [
       {
         "name": "Account Management & Customer Success",
         "id": 28939,
         "jobId": 18422,
         "departmentId": 44
       }
     ],
     "description": "At Flo Recruit, we are a venture-backed, high-growth SaaS software company building recruiting solutions for the Legal industry.",
     "applyUrl": "https://florecruit.com/v2/app/florecruit/jobs/MjE3MDN-S1g1T2NjVEw4N2s5aWZRRm9WVlpqZ2ZOdXBrNGdx/apply" 
   }
]
```

### Retrieve job details (one request per job) <a href="#h_275f355cc5" id="h_275f355cc5"></a>

| Method | Endpoint                                                                                        | Description                                                                                                                                                                                   |
| ------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | <https://florecruit.com/api/v2/public-jobs/[org-name]/career-page-jobs/[extension]/job-details> | <p>Retrieve list of public jobs</p><p>Note: org-name is your friendly organization name in Flo's platform</p><p>“Extension” is the unique ID of the job returned in the job list request.</p> |

Sample Request/Response:

Request

<https://florecruit.com/api/v2/public-jobs/florecruit/career-page-jobs/MTQ5NjR-RkpwcUZOTDRibE1IeEtWcWRleUlFM0ZwelFHeXVx/job-details>

Response

```
{
   "id": 14964,
   "extension": "MTQ5NjR-RkpwcUZOTDRibE1IeEtWcWRleUlFM0ZwelFHeXVx",
   "title": "Full Stack Software Engineer II",
   "openDate": "2024-09-10T00:01:00",
   "closeDate": null,
   "openTimezone": "America/Chicago",
   "closeTimezone": "America/Chicago",
   "isPublic": true,
   "jobEmploymentTypes": [
     {
       "employmentType": "Full-Time",
       "id": 22237,
       "jobId": 14964,
       "employmentTypeId": 37
     }
   ],
   "jobOffices": [
     {
       "name": "Austin, Texas",
       "id": 14137,
"officeId": 967,
       "jobId": 14964
     }
   ],
   "hiringLeads": [
     {
       "id": 88470,
       "email": "christina@florecruit.com",
       "fname": "Christina",
       "lname": "Wells"
     }
   ],
   "positionTypes": [
     {
       "name": "Mid Level",
       "id": 22237,
       "jobId": 14964,
       "positionTypeId": 33
     }
   ],
   "departments": [
     {
       "name": "Engineering",
       "id": 22238,
       "jobId": 14964,
       "departmentId": 45
     }
   ],
   "jobId": null,
   "description": "\u003Cp\u003E\u003Cstrong\u003EWho we are\u003C/strong\u003E\u003C/p\u003E\u003Cp\u003EFlo Recruit is a venture-backed, high-growth SaaS company that develops virtual recruiting solutions for universities and employers. We've established a strong foothold in the legal industry, serving over 65 BigLaw firms and 70 top law schools. Our software is the go-to platform for clients managing virtual recruiting processes, including interviews and networking events.\u003C/p\u003E\u003Cp\u003E\u003C/p\u003E\u003Cp\u003E\u003Cstrong\u003EAbout the role\u003C/strong\u003E\u003C/p\u003E\u003Cp\u003EAt Flo, we develop and maintain product solutions for three key domains: employers, students, and universities. In this role, you'll work on building, strengthening, and innovating our university product suite. You'll also have the opportunity to be an integral part of our engineering department, helping to mature our culture and coding practices.\u003C/p\u003E\u003Cp\u003E\u003C/p\u003E\u003Cp\u003E\u003Cstrong\u003EWhat you’ll do\u003C/strong\u003E\u003C/p\u003E\u003Cul\u003E\u003Cli\u003E\u003Cp\u003EGuide the technical direction and implementation of medium to large-scale product features\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003ECollaborate with product, design, and engineering teams to positively influence product strategy and direction\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EWork closely with Product Managers to groom user stories, including providing design and implementation details, estimating effort, and contributing to acceptance criteria\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EChampion and contribute to our efforts in shipping maintainable, reliable, well-tested and secure code\u003C/p\u003E\u003C/li\u003E\u003C/ul\u003E\u003Cp\u003E\u003Cstrong\u003ERequirements\u003C/strong\u003E\u003C/p\u003E\u003Cul\u003E\u003Cli\u003E\u003Cp\u003E3-5 years of industry experience working as a Full Stack Engineer\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EExperience with one or more prominent server-side languages (e.g. Java, C#, Kotlin, or Python)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EWorking knowledge of server-side frameworks (e.g. Spring, .NET, Node, Django)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EExperience with JavaScript/TypeScript, HTML5, CSS, and experience with JavaScript frameworks (e.g. React and Next)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EFamiliarity with modern testing frameworks (e.g. Jest, Cypress, React Testing Library)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EGood understanding of front-end and backend architecture, as well as test-driven development\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EExperience building Rest APIs, GraphQL\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EFluency in SQL languages (e.g. MySQL, MS SQL Server, PostgreSQL)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EAbility to transfer your depth of knowledge from your current language to excel as a Java developer\u003C/p\u003E\u003C/li\u003E\u003C/ul\u003E\u003Cp\u003E\u003Cstrong\u003EPreferred\u003C/strong\u003E\u003C/p\u003E\u003Cul\u003E\u003Cli\u003E\u003Cp\u003EExperience with Java\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EFamiliarity with containerization technologies (e.g. Docker, K8s)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EKnowledge and understanding of messaging and streaming technologies (e.g. Kafka, RabbitMQ, Redis)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EHands-on experience with cloud infrastructure technologies (e.g. AWS, Azure, Google Cloud)\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EExperience designing and strengthening database schema for large-scale and performant service\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EExperience working in a fast-paced environment\u003C/p\u003E\u003C/li\u003E\u003C/ul\u003E\u003Cp\u003E\u003Cstrong\u003EBenefits\u003C/strong\u003E\u003C/p\u003E\u003Cul\u003E\u003Cli\u003E\u003Cp\u003EMarket-competitive compensation package consisting of base salary and meaningful employee stock option grants\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EFull offering of healthcare benefits, including vision and dental; cost partially subsidized by Flo\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EGenerous PTO, personal and sick leave policy\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003E12 company-paid holidays\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003E401K\u003C/p\u003E\u003C/li\u003E\u003Cli\u003E\u003Cp\u003EParental leave policy\u003C/p\u003E\u003Cp\u003E\u003C/p\u003E\u003C/li\u003E\u003C/ul\u003E\u003Cp\u003E\u003Cstrong\u003ERemote Policy\u003C/strong\u003E\u003C/p\u003E\u003Cp\u003EFlo Recruit is headquartered in Austin, TX. We are looking for someone who lives within a commutable distance to the office or willing to relocate. Engineers are expected to come into the office at least once a month for company-wide All-Hands. We have a central Austin office available to those who prefer to work in person. \u003C/p\u003E\u003Cp\u003E\u003C/p\u003E\u003Cp\u003E\u003Cstrong\u003EWe do not sponsor visas or provide relocation assistance. \u003C/strong\u003E\u003C/p\u003E\u003Cp\u003E\u003C/p\u003E\u003Cp\u003E\u003Cbr\u003E\u003C/p\u003E\u003Cp\u003E\u003Cstrong\u003EU.S. Equal Employment Opportunity/Affirmative Action Information\u003C/strong\u003E\u003C/p\u003E\u003Cp\u003EFlo Recruit Inc. is proud to be an equal opportunity employer and will consider all qualified individuals seeking employment without regard to race, color, creed, religion, gender, gender identity, national origin, citizenship, age, sex, marital status, ancestry, physical or mental disability, veteran status, sexual orientation, or any other protected classification\u003C/p\u003E",
"applyUrl": "https://florecruit.com/v2/app/florecruit/jobs/MjE3MDN-S1g1T2NjVEw4N2s5aWZRRm9WVlpqZ2ZOdXBrNGdx/apply"
 }
```

### Creating an “Apply” button: <a href="#h_dbaa698f55" id="h_dbaa698f55"></a>

Here is the URL that you can use for an Apply button that will direct the candidate to Flo Recruit’s application page for a specific job:

<https://florecruit.com/v2/app/[org-name]/jobs/[extension]/apply>


# User API


# Members CSV Import

Reference for the file format accepted by the members CSV import endpoint. Use alongside the OpenAPI spec for full request/response schemas.

### Endpoints <a href="#endpoints" id="endpoints"></a>

| Method | Path                                                 | Purpose                                                                                                                                                                                |
| ------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/members/import-csv?product={Product}`              | Creates new members and updates existing members from CSV input. Returns created and updated counts, or a detailed error response. Rejects all rows if any single row contains errors. |
| `GET`  | `/members/example-csv/{product}/{numRows}`           | Downloads a sample CSV pre-populated with example rows appropriate for your org's office locations, SSO configuration, etc. `numRows` is capped at 1000.                               |
| `GET`  | `/members/import-instructions.pdf?product={Product}` | Downloads a PDF reference guide for the import flow. Recruit/University variants omit the Performance-only columns.                                                                    |

Both write paths accept `multipart/form-data` with the CSV provided in the `file` part. The `product` query parameter is required on `import-csv` and is one of the values defined by the `Product` enum in the OpenAPI spec (e.g. `PERFORM`, `RECRUIT`).

#### Authorization <a href="#authorization" id="authorization"></a>

* `product=PERFORM` requires the **Perform Admin** role.
* All other products require the **Admin** role.

### File-level requirements <a href="#file-level-requirements" id="file-level-requirements"></a>

| Requirement   | Value                                                                                                                                                                                                  |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Encoding      | UTF-8. An optional UTF-8 BOM is accepted and stripped.                                                                                                                                                 |
| Max file size | 10 MB                                                                                                                                                                                                  |
| Line endings  | LF or CRLF                                                                                                                                                                                             |
| Header row    | Required. Must include at least the three required columns (see [Header rules](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#header-rules)). |

There is no enforced row-count cap, but very large files should respect the 10 MB size limit.

### Header rules <a href="#header-rules" id="header-rules"></a>

Column matching is **case-insensitive** and **order-independent**. Header rules are otherwise strict:

* **Required headers must be present.** `First Name`, `Last Name`, and `Email` must all appear in the header row. Missing any of them rejects the file with `MISSING_REQUIRED_COLUMNS`.
* **Unknown headers reject the file.** Any header not in the recognized column list below causes a `UNEXPECTED_COLUMNS` file error. There is no "ignore extras" behavior — clean up your column names before submitting.
* **Which columns you include matters for updates.** When updating an existing member, the *presence* of a column header (independent of whether its cells are blank) controls whether that field is preserved or cleared. See [Update semantics](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#update-semantics) below.

#### Recognized headers (case-insensitive) <a href="#recognized-headers-case-insensitive" id="recognized-headers-case-insensitive"></a>

```
Employee ID, First Name, Last Name, Email, Job Title, Level,
Office City, Office State (US Only), Office Country (Non-US Only),
Department, Practice Area, Law School, Graduation Year,
Effective Class Year, Start Date, Role, SSO ID, Use MFA,
Bio Link, Work Arrangement
```

### Column reference <a href="#column-reference" id="column-reference"></a>

All required-ness, type, and constraint information below reflects the **actual validator behavior**. A few items here differ from the inline `description` text on the endpoint — when in doubt, this document is the source of truth.

| Column                           | Type           | Required | Constraints                                                                                                                                                                                                                                     | Example                       |
| -------------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| **First Name**                   | string         | Yes      | Must not contain: backslash, carriage return, newline, `<`, `>`, \`                                                                                                                                                                             | `,`"\`, or backtick.          |
| **Last Name**                    | string         | Yes      | Same disallowed-character set as First Name.                                                                                                                                                                                                    | `Smith`                       |
| **Email**                        | string         | Yes      | Must be a well-formed email address. Must be unique across rows in the file (case-insensitive).                                                                                                                                                 | `jane.smith@firm.com`         |
| **Employee ID**                  | string         | No       | Treated as an opaque identifier. When provided, must be unique across rows in the file (case-insensitive). Not required to be numeric.                                                                                                          | `E-1042`                      |
| **Job Title**                    | string         | No       | None.                                                                                                                                                                                                                                           | `Senior Associate`            |
| **Level**                        | string         | No       | Must match one of the org's configured member levels (case-insensitive). **Only persisted when `product=PERFORM`** — silently ignored for other products.                                                                                       | `Senior`                      |
| **Office City**                  | string         | No       | When provided, exactly one of Office State or Office Country must also be provided. See [Office location rules](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#office-location-rules). | `New York`                    |
| **Office State (US Only)**       | string         | No       | Mutually exclusive with Office Country. Must accompany Office City when set.                                                                                                                                                                    | `NY`                          |
| **Office Country (Non-US Only)** | string         | No       | Mutually exclusive with Office State. Must accompany Office City when set.                                                                                                                                                                      | `United Kingdom`              |
| **Department**                   | string         | No       | When provided, must match one of the org's configured departments.                                                                                                                                                                              | `Litigation`                  |
| **Practice Area**                | string         | No       | When provided, must match one of the org's configured practice areas.                                                                                                                                                                           | `Corporate`                   |
| **Law School**                   | string         | No       | When provided, must match one of the configured law schools. See [Education writes](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#education-writes) for upsert behavior.              | `Columbia Law School`         |
| **Graduation Year**              | string         | No       | 4 digits, must be within 200 years of the current year. **Only persisted when `product=PERFORM`** — silently ignored for other products.                                                                                                        | `2018`                        |
| **Effective Class Year**         | string         | No       | 4 digits, must be within 200 years of the current year. **Only persisted when `product=PERFORM`** — silently ignored for other products.                                                                                                        | `2020`                        |
| **Start Date**                   | date           | No       | Format `yyyy-MM-dd`.                                                                                                                                                                                                                            | `2025-06-03`                  |
| **Role**                         | enum           | No       | `ADMIN` or `MEMBER` (case-sensitive). If omitted or blank on a new member, defaults to `MEMBER`.                                                                                                                                                | `MEMBER`                      |
| **SSO ID**                       | string         | No       | Required only when your org uses SSO and is not configured to use email-as-SSO-ID. Otherwise ignored. Specific validation depends on your org's SSO configuration.                                                                              | `jsmith`                      |
| **Use MFA**                      | boolean string | No       | One of (case-insensitive): `true`, `t`, `1`, `yes`, `y`, `false`, `f`, `0`, `no`, `n`.                                                                                                                                                          | `true`                        |
| **Bio Link**                     | URL            | No       | Must be a syntactically valid URL.                                                                                                                                                                                                              | `https://firm.com/bio/jsmith` |
| **Work Arrangement**             | enum           | No       | One of: `REMOTE`, `HYBRID`, `IN_PERSON` (case-sensitive).                                                                                                                                                                                       | `HYBRID`                      |

#### Field handling rules that apply to every column <a href="#field-handling-rules-that-apply-to-every-column" id="field-handling-rules-that-apply-to-every-column"></a>

* **Leading and trailing whitespace is trimmed** from every value before validation.
* A field whose content is whitespace-only is treated as **missing** (`null`).
* Embedded commas, double quotes, and newlines in a value must be CSV-escaped per RFC 4180 (wrap the value in double quotes; double up internal `"`).

### Office location rules <a href="#office-location-rules" id="office-location-rules"></a>

The Office City / Office State / Office Country triad is validated as a unit. All four combinations below are valid:

| City | State | Country | Valid?              |
| ---- | ----- | ------- | ------------------- |
| —    | —     | —       | Yes (no office set) |
| set  | set   | —       | Yes (US office)     |
| set  | —     | set     | Yes (non-US office) |
| —    | —     | —       | Yes                 |

Anything else produces a `ROW_VALUE_CONFLICT` or `EMPTY_REQUIRED_VALUE` row error:

* Office State or Office Country without Office City → conflict on the offending field.
* Office State **and** Office Country both set on the same row → conflict on both.
* Office City set with neither State nor Country → missing-required on both State and Country.

Beyond format-correctness, the resulting office location must match one of your org's configured offices (`INVALID_LIST_SELECTION` if it does not).

### Discovering valid list values <a href="#discovering-valid-list-values" id="discovering-valid-list-values"></a>

Five CSV columns require values that match data configured for your org or maintained globally by Flo. Use the endpoints below to retrieve the current set of valid values before building your CSV. All listing endpoints require admin authentication using the same scheme as `/members/import-csv`.

| CSV column                        | Endpoint                                                                   | Scope                                                 | Returns                                                                                                                                                                                                                                                                                                                                                              |
| --------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Law School**                    | `GET /lawSchoolsList.csv`                                                  | Global (same for every org)                           | CSV with the full list of recognized law schools. **A snapshot is also embedded below at** [**Law School values**](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#law-school-values) **for convenience.**                                                                                                   |
| **Practice Area**                 | `GET /practiceAreas/current` (JSON), or `GET /practiceAreasList.csv` (CSV) | Global static list + your org's custom list, combined | Every practice area that will pass validation for your org, each tagged with its type (static vs. org-custom). The static list alone is available at `GET /practiceAreasStaticList`; your org's custom list alone is available at `GET /firmPracticeAreasList`.                                                                                                      |
| **Department**                    | `GET /departments` (JSON), or `GET /departments.csv` (CSV)                 | Per-org                                               | Your org's configured departments.                                                                                                                                                                                                                                                                                                                                   |
| **Office City / State / Country** | `GET /offices` (JSON)                                                      | Per-org                                               | Your org's configured office locations, each as a (city, state, country) triple. Populate the three Office columns in your CSV to exactly match one of these triples — see [Office location rules](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#office-location-rules) for the format rules within a row. |
| **Level**                         | `GET /org-member-levels` (JSON)                                            | Per-org (Performance only)                            | Your org's configured member levels. Returned only when your org has the member-levels feature enabled. Non-Performance imports ignore the `Level` column regardless of the list.                                                                                                                                                                                    |

#### Law School values <a href="#law-school-values" id="law-school-values"></a>

**Snapshot as of 2026-06-02.** \~330 recognized law schools, sorted case-insensitively. Re-fetch from `GET /lawSchoolsList.csv` for the latest set — additions happen periodically.

<details>

<summary>Click to expand: full law school list (sorted case-insensitively)</summary>

```
```

</details>

#### Notes on using these endpoints <a href="#notes-on-using-these-endpoints" id="notes-on-using-these-endpoints"></a>

* **Lists can change between imports.** New law schools are added over time; orgs can add/edit their own departments, offices, practice areas, and member levels. Re-fetch before each large import rather than caching long-term.
* **Comparison in member-import validation is case-insensitive but otherwise exact.** Trailing whitespace in your CSV is trimmed before comparison; internal whitespace is not normalized — `"New York"` (two spaces) will not match `"New York"`.
* **Practice-area availability is feature-flag gated.** Your org's custom practice areas only count as valid values when the `custom-practice-areas` feature is enabled for your org. `GET /practiceAreas/current` returns the actual merged set that will pass validation, so always prefer it over the static-only endpoint for pre-import checks.
* **`/officesStaticList` exists but is not the right list for imports.** It returns a global suggestion list used by the office-creation UI; member-import validation runs against your org's *configured* offices from `GET /offices`.

#### Fixed-enum columns (no endpoint needed) <a href="#fixed-enum-columns-no-endpoint-needed" id="fixed-enum-columns-no-endpoint-needed"></a>

The following columns accept a small fixed set of values that doesn't vary per org. The accepted values are listed directly in the [Column reference](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#column-reference) and reproduced here for convenience:

| Column               | Accepted values                                                                  |
| -------------------- | -------------------------------------------------------------------------------- |
| **Role**             | `ADMIN`, `MEMBER` (case-sensitive). Defaults to `MEMBER` on new rows when blank. |
| **Work Arrangement** | `REMOTE`, `HYBRID`, `IN_PERSON` (case-sensitive).                                |
| **Use MFA**          | `true`, `t`, `1`, `yes`, `y`, `false`, `f`, `0`, `no`, `n` (case-insensitive).   |

### Cross-row rules <a href="#cross-row-rules" id="cross-row-rules"></a>

* **Duplicate Email across rows** → `DUPLICATE_VALUE` on every involved row (case-insensitive comparison).
* **Duplicate Employee ID across rows** → `DUPLICATE_VALUE` on every involved row (case-insensitive comparison). Empty Employee IDs are not considered duplicates of each other.

### Recommended workflow <a href="#recommended-workflow" id="recommended-workflow"></a>

There is no separate "validate" endpoint — `/members/import-csv` performs validation and the write in a single transaction:

1. Build the CSV. To get a template tailored to your org (with valid office locations, SSO id format, etc.), `GET /members/example-csv/{product}/{numRows}`.
2. `POST /members/import-csv?product={Product}` with the file.
3. On `200 OK`, parse `createdCount` and `updatedCount` from the response body.
4. On `400 Bad Request`, parse the validation result body to determine whether the failure was file-level (`fileError`) or row-level (`rowColumnErrors`). Fix and retry; the call is rejected as a whole, so partial state is never written.

### Update semantics <a href="#update-semantics" id="update-semantics"></a>

Members are matched by **Email** (case-insensitive). A row with an Email that matches an existing member in your org becomes an **update**; otherwise it becomes a **create**.

#### Updates: present-column rule <a href="#updates-present-column-rule" id="updates-present-column-rule"></a>

For an existing member, the *presence of a column header* in your file controls whether that field gets touched:

| Situation                                     | Effect on the existing field                        |
| --------------------------------------------- | --------------------------------------------------- |
| Column header **absent** from the CSV         | Existing value is **preserved**.                    |
| Column header **present**, cell **non-blank** | Existing value is **replaced** with the cell value. |
| Column header **present**, cell **blank**     | Existing value is **cleared**.                      |

This lets you do precise partial updates: include only the columns you want to change.

**Exceptions and special cases:**

* **First Name, Last Name, Email** are always required regardless. Their headers must be present and their cells non-blank on every row.
* **Office City / Office State / Office Country** are treated as a single unit. If none of the three column headers are present, the existing office location is preserved. If any one of them is present, all three are taken from the CSV (with the usual triad rules).
* **Level, Graduation Year, Effective Class Year** are removed from the present-columns set on non-Performance imports — even if you include those headers, the columns will be treated as absent for update purposes (preserved, not cleared).
* **SSO ID** and **Use MFA** are also context-merged before SSO/MFA validation runs — if only one of the two columns is present, the absent column's value is taken from the existing user record before validation, so you can update one without restating the other.

#### Education writes (Law School, Graduation Year) <a href="#education-writes-law-school-graduation-year" id="education-writes-law-school-graduation-year"></a>

Education-background writes on existing members are **create-only**: if the member already has any education record, additional ones are skipped. Use the dedicated profile-edit flows to modify or replace existing education records — the CSV will not overwrite them.

#### Creates <a href="#creates" id="creates"></a>

For new members, every required field on the row must pass validation. The default Role is `MEMBER` if Role is blank or absent. Welcome-email side effects fire per row.

### Updating a member after creation <a href="#updating-a-member-after-creation" id="updating-a-member-after-creation"></a>

There is no separate "update" endpoint for individual members on the REST API — updates are performed by submitting CSV rows whose **Email** matches an existing member. The same `POST /members/import-csv` endpoint handles both create and update; the [Update semantics](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#update-semantics) section above describes the underlying rules. This section shows how to apply them in practice.

#### The general recipe <a href="#the-general-recipe" id="the-general-recipe"></a>

1. Build a CSV containing **only the columns whose values you want to change**, plus the three always-required columns (`First Name`, `Last Name`, `Email`).
2. Include one row per member to update. Match by **Email** (case-insensitive).
3. `POST /members/import-csv?product={Product}` with the file.
4. On `200 OK`, `updatedCount` reflects how many existing members were touched. Any row whose email did not match an existing member is created instead and counts toward `createdCount`.

Remember: a column **header** present with a **blank** cell is interpreted as "clear this field," while an absent header is "preserve this field." This is the single most common source of accidental data loss — be deliberate about which columns you include in your update file.

#### Recipe 1: change one or two fields, leave the rest untouched <a href="#recipe-1-change-one-or-two-fields-leave-the-rest-untouched" id="recipe-1-change-one-or-two-fields-leave-the-rest-untouched"></a>

To update only the job title and work arrangement for an existing member, include exactly those columns alongside the required identifiers:

```csv
First Name,Last Name,Email,Job Title,Work Arrangement
Jane,Smith,jane.smith@firm.com,Senior Associate,HYBRID
```

Everything else — office location, department, practice area, law school, employee ID, levels, dates, role, SSO settings — is preserved because those columns are absent from the file.

#### Recipe 2: clear a field <a href="#recipe-2-clear-a-field" id="recipe-2-clear-a-field"></a>

Include the column header but leave the cell empty. To remove a member's bio link:

```csv
First Name,Last Name,Email,Bio Link
Jane,Smith,jane.smith@firm.com,
```

The bio link is set to empty on the existing record. All other fields are preserved.

#### Recipe 3: move a member to a new office <a href="#recipe-3-move-a-member-to-a-new-office" id="recipe-3-move-a-member-to-a-new-office"></a>

The office triad (`Office City`, `Office State (US Only)`, `Office Country (Non-US Only)`) is treated as a unit. Include all three columns to be explicit about the new location:

```csv
First Name,Last Name,Email,Office City,Office State (US Only),Office Country (Non-US Only)
Jane,Smith,jane.smith@firm.com,London,,United Kingdom
```

Omit all three columns to preserve the existing office.

#### Recipe 4: promote a member to admin (or demote) <a href="#recipe-4-promote-a-member-to-admin-or-demote" id="recipe-4-promote-a-member-to-admin-or-demote"></a>

Include the `Role` column. The role applies to the product the upload is targeted at (`?product=…`).

```csv
First Name,Last Name,Email,Role
Jane,Smith,jane.smith@firm.com,ADMIN
```

#### Recipe 5: grant an existing member access to an additional product <a href="#recipe-5-grant-an-existing-member-access-to-an-additional-product" id="recipe-5-grant-an-existing-member-access-to-an-additional-product"></a>

Submit the CSV against the **new** product. The import treats a row whose email matches an existing member but who doesn't yet have the target product role as a "grant product access" operation. A welcome email is sent for the newly-granted product.

```bash
curl -X POST 'https://<host>/api/v2/members/import-csv?product=PERFORM' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@grant-perform.csv'
```

```csv
First Name,Last Name,Email
Jane,Smith,jane.smith@firm.com
```

#### Things to be careful about <a href="#things-to-be-careful-about" id="things-to-be-careful-about"></a>

* **Match is by email only.** If your records use a member's old email and the email on file has changed, you'll create a new member instead of updating the old one. To change a member's email, use the GraphQL path described below — there is no email-change column in the CSV.
* **Education records are create-only.** Setting `Law School` and `Graduation Year` on a re-upload will not overwrite existing education records. To change a member's law school after creation, contact Flo support.
* **Performance-only columns are silently ignored on non-Performance uploads.** `Level`, `Graduation Year`, and `Effective Class Year` are dropped at parse time when the request specifies a non-Performance product. To update those, target `product=PERFORM`.
* **Welcome emails fire when a member is granted a new product role.** If your re-upload is granting access to a previously-untouched product, expect notification side-effects (Recipe 5). If it's purely a field-update on a product the member already has, no email fires.
* **There is no separate dry-run mode.** Plan your column selection before submitting — the change is committed when the request succeeds. If your integration is risk-averse, send a single-row file first for a representative member, verify the result with a `memberById` query (GraphQL) or by visual inspection, then submit the rest in batch.

#### Alternative: GraphQL mutations for per-member updates <a href="#alternative-graphql-mutations-for-per-member-updates" id="alternative-graphql-mutations-for-per-member-updates"></a>

For integrations that need finer-grained control (single-member updates, partial updates without constructing a CSV, post-create email changes, deletions) the v2 GraphQL endpoint at `POST /graphql` exposes the following mutations:

| Mutation                           | What it does                                                                                                                                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `editEmployerMemberInfo`           | Update first/last name, employment (office, department, practice area, level, start date, effective class year), employee ID, bio link, law school, graduation year, work arrangement, job title. |
| `editEmployerMemberAuthInfo`       | Update product roles (ADMIN ↔ MEMBER), SSO/MFA settings.                                                                                                                                          |
| `editUniversityMemberInfo`         | University-side counterpart: update first/last name.                                                                                                                                              |
| `addEmployerMemberProductUserRole` | Grant an existing member access to an additional product (per-member equivalent of CSV Recipe 5).                                                                                                 |
| `revokeProductUserAccess`          | Remove a member's access to a product.                                                                                                                                                            |
| `deleteMember`                     | Delete a member entirely.                                                                                                                                                                         |

Each mutation takes a member `id` (the GraphQL `ID` returned at creation, or queryable via the `memberById` / `membersByProduct` queries). Authentication is the same as the CSV endpoint. Full request/response shapes are in the GraphQL schema at `member.schema.graphql`; the OpenAPI spec covers the REST surface area only. Reach out to your Flo contact if you need a GraphQL onboarding guide.

### Error response shape <a href="#error-response-shape" id="error-response-shape"></a>

`/members/import-csv` returns `400 Bad Request` with this JSON body on any validation failure:

```json
{
  "fileError": null,
  "rowColumnErrors": [
    {
      "rowNumber": 2,
      "columnName": "Email",
      "value": "jane@firm.com",
      "type": "DUPLICATE_VALUE",
      "message": "Duplicate value: jane@firm.com found in rows [2, 5]"
    }
  ]
}
```

Exactly one of `fileError` or `rowColumnErrors` is populated.

#### File-level errors (`fileError.type`) <a href="#file-level-errors-fileerrortype" id="file-level-errors-fileerrortype"></a>

| Type                       | Meaning                                                                       |
| -------------------------- | ----------------------------------------------------------------------------- |
| `EMPTY_FILE`               | The file is missing or empty.                                                 |
| `FILE_SIZE_EXCEEDED`       | The file is larger than 10 MB.                                                |
| `MISSING_REQUIRED_COLUMNS` | The header row is missing First Name, Last Name, or Email.                    |
| `UNEXPECTED_COLUMNS`       | The header row contains a column not in the recognized list.                  |
| `INVALID_FILE_FORMAT`      | Parse failure (malformed CSV, encoding issue, or an unexpected parser error). |

#### Row-level error types (`rowColumnErrors[].type`) <a href="#row-level-error-types-rowcolumnerrorstype" id="row-level-error-types-rowcolumnerrorstype"></a>

| Type                     | Triggers                                                                                                                  |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `EMPTY_REQUIRED_VALUE`   | A required field is missing, or an Office triad rule requires a value that wasn't provided.                               |
| `INVALID_EMAIL`          | Email is not a well-formed address.                                                                                       |
| `INVALID_FORMAT`         | First/Last Name contains a disallowed special character, **or** Use MFA isn't one of the accepted boolean strings.        |
| `INVALID_URL`            | Bio Link is not a syntactically valid URL.                                                                                |
| `INVALID_YEAR`           | Graduation Year / Effective Class Year is not 4 digits or is more than 200 years from the current year.                   |
| `INVALID_DATE_FORMAT`    | Start Date isn't in `yyyy-MM-dd`.                                                                                         |
| `DUPLICATE_VALUE`        | Email or Employee ID appears on two or more rows. The error message lists the colliding row numbers.                      |
| `INVALID_LIST_SELECTION` | A list field (Office Location, Practice Area, Law School, Department, or Level) doesn't match a known value for your org. |
| `INVALID_SSO_ID`         | SSO ID failed your org's SSO validation rules.                                                                            |
| `ROW_VALUE_CONFLICT`     | The Office triad on this row is internally inconsistent.                                                                  |
| `EXISTING_USER_CONFLICT` | A user conflict reported by the create path (e.g. an existing user in a different org).                                   |
| `UNKNOWN`                | An unmapped exception from the create/update path. Inspect the `message` field.                                           |

### Success response <a href="#success-response" id="success-response"></a>

`200 OK` with:

```json
{ "createdCount": 5, "updatedCount": 3 }
```

`createdCount` is the number of new members created. `updatedCount` is the number of existing members updated.

### Sample CSV <a href="#sample-csv" id="sample-csv"></a>

A minimal valid file using only the required columns:

```csv
First Name,Last Name,Email
Jane,Smith,jane.smith@firm.com
John,Doe,john.doe@firm.com
```

A fuller file targeting `product=PERFORM`:

```csv
Employee ID,First Name,Last Name,Email,Job Title,Level,Office City,Office State (US Only),Office Country (Non-US Only),Department,Practice Area,Law School,Graduation Year,Effective Class Year,Start Date,Role,SSO ID,Use MFA,Bio Link,Work Arrangement
E-1042,Jane,Smith,jane.smith@firm.com,Senior Associate,Senior,New York,NY,,Litigation,Corporate,Columbia Law School,2018,2020,2024-06-03,MEMBER,jsmith,true,https://firm.com/bio/jsmith,HYBRID
E-1043,John,Doe,john.doe@firm.com,Partner,Partner,,,United Kingdom,Corporate,M&A,Yale Law School,2010,2012,2014-09-01,ADMIN,jdoe,true,,IN_PERSON
```

The second row demonstrates a non-US office (Office Country set, Office State empty) and an empty Bio Link.

### Example: curl <a href="#example-curl" id="example-curl"></a>

Download a 5-row template for the Recruit product:

```bash
curl -X GET 'https://<host>/members/example-csv/RECRUIT/5' \
  -H 'Authorization: Bearer <token>' \
  -o template.csv
```

Import:

```bash
curl -X POST 'https://<host>/members/import-csv?product=PERFORM' \
  -H 'Authorization: Bearer <token>' \
  -F 'file=@members.csv'
```

### Common pitfalls <a href="#common-pitfalls" id="common-pitfalls"></a>

| Symptom                                                                | Likely cause                                                                                                                                                                                                              |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNEXPECTED_COLUMNS` rejection                                         | A header was spelled differently, has a typo, or includes a column from a different system. The match is case-insensitive but otherwise exact — `Hire Date` will be rejected because `Start Date` is the recognized name. |
| `MISSING_REQUIRED_COLUMNS` rejection                                   | One of First Name / Last Name / Email is not in the header row.                                                                                                                                                           |
| Existing member's field unexpectedly **cleared** to blank              | Your CSV included that column header with a blank cell. To preserve a field on an update, omit its column header from the file entirely.                                                                                  |
| Existing member's field unexpectedly **unchanged**                     | The reverse: you set a non-blank value but the column header was missing from your file.                                                                                                                                  |
| All Office fields flagged                                              | The Office triad rule was violated — see [Office location rules](http://localhost:63342/markdownPreview/908541821/markdown-preview-index-26vd40t631gtlv10sm4r3ve57r.html#office-location-rules).                          |
| Level / Graduation Year not persisted                                  | `product` was not `PERFORM`. These three fields are silently ignored on non-Performance imports.                                                                                                                          |
| Law School / Graduation Year not applied to an existing member         | Education writes are create-only — once a member has any education record, the CSV will not overwrite it.                                                                                                                 |
| `DUPLICATE_VALUE` on Employee ID across rows you thought were distinct | Comparison is case-insensitive — `E-1042` and `e-1042` collide.                                                                                                                                                           |
| `INVALID_LIST_SELECTION` on a value that exists in your UI             | The list match is case-insensitive but otherwise exact and trims surrounding whitespace. Embedded extra whitespace (`"New York"`) will not match `"New York"`.                                                            |
| `INVALID_FORMAT` on a Name field                                       | First or Last Name contained one of the disallowed characters: `\`, `<`, `>`, \`                                                                                                                                          |
| Members in the file aren't reflected as updates                        | Match is by Email (case-insensitive). If the CSV email differs from what's on file in any way other than case (extra dots, plus addressing, alias), the row becomes a create attempt, not an update.                      |

<br>


# Members Bulk Remove

### Overview

Reference for the bulk member-removal endpoint, which removes product access for many members in one call, identifying each member by email address. Use alongside the OpenAPI spec for full request/response schemas.

This is the counterpart to `POST /members/import-csv` (see *Members CSV Import*). The import endpoint creates and updates members from a CSV; this endpoint removes product access for members that already exist. It takes a JSON body — there is no CSV form of bulk removal.

### Endpoint

| Method   | Path                   | Purpose                                                                                                                                                                             |
| -------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DELETE` | `/members/bulk-remove` | Removes access to one product for the listed members. Only members whose role for that product is `MEMBER` are affected; admins are skipped. Returns the number of members removed. |

Content type is `application/json`. Unlike `import-csv`, the product is supplied in the request body rather than as a query parameter.

### Authorization

* `product=PERFORM` requires the **Perform Admin** role.
* All other products require the **Admin** role.
* Authentication is the same scheme as `/members/import-csv`.

The endpoint is additionally gated by the `user-list-members-bulk-delete` feature flag. If the flag is not enabled for your org, every call returns `403 Forbidden` and nothing is removed. Contact your Flo representative to have it enabled.

### Request body

```json
{
  "product": "RECRUIT",
  "emails": ["jane.smith@firm.com", "john.doe@firm.com"]
}
```

| Field     | Type            | Required | Notes                                                                                                                                     |
| --------- | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `product` | enum            | Yes      | One of `RECRUIT`, `PERFORM`, `UNI`. The product whose access is being removed. Determines the required admin role (see Authorization).    |
| `emails`  | array of string | No       | Email addresses of the members to remove. Omitting the field, or sending `null` or `[]`, is a valid no-op that returns `removedCount: 0`. |

There is no enforced cap on the number of emails, but the whole call runs in a single transaction — prefer batches of a few thousand over one very large request.

### Matching semantics

* Members are matched by **email address**, against the email stored on the member record in your org. Members in other orgs are never visible to the call.
* The API performs **no normalization** on the values you send — no trimming, no case folding, no alias resolution. Send the exact email on file.
* An email that matches nothing is **silently ignored**. It is not an error, and it does not appear in the response; it simply doesn't contribute to `removedCount`. A call whose every email is unknown returns `200 OK` with `removedCount: 0`.
* Duplicate emails in the array are harmless — each member is removed at most once, so `removedCount` counts members, not array entries.

### What gets removed — and what doesn't

The operation removes the member's **access to the one product named in the request**. It is not a member deletion.

|                                                         | Effect                                              |
| ------------------------------------------------------- | --------------------------------------------------- |
| The member's access to the requested product            | Removed                                             |
| The member's access to other products                   | Preserved                                           |
| The member's user record, profile, and employment data  | Preserved                                           |
| Members whose role for the requested product is `ADMIN` | **Skipped** — not removed, not reported as an error |

Because admins are skipped rather than rejected, including an admin's email in the list is safe: the rest of the batch still processes, and the admin's access is left intact. The only signal is that `removedCount` comes back lower than the number of emails you sent.

To remove an admin, first demote them to `MEMBER` (via `import-csv` with a `Role` column of `MEMBER`, or the `editEmployerMemberAuthInfo` GraphQL mutation), then re-run the bulk remove. To delete a member entirely rather than revoking one product, use the `deleteMember` GraphQL mutation — see *Alternative: GraphQL mutations* below.

### Success response

`200 OK` with:

```json
{ "removedCount": 2 }
```

`removedCount` is the number of members whose product access was actually removed. It excludes skipped admins and unmatched emails, so it is the authoritative signal of what happened — always compare it against the number of emails you sent.

### Error responses

| Status             | Meaning                                                                                                                                                                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401 Unauthorized` | The request is unauthenticated.                                                                                                                                                            |
| `403 Forbidden`    | Either the caller lacks the required role for the product (Perform Admin for `PERFORM`, Admin otherwise), or the `user-list-members-bulk-delete` feature flag is not enabled for your org. |

There is no row-level error response and no partial-failure body — unlike `import-csv`, this endpoint has nothing to validate per email. An email is either matched (and removed) or ignored. The call is a single transaction: on any failure nothing is removed.

### Recommended workflow

1. Determine the product you are removing access from, and confirm you hold the matching admin role.
2. Collect the exact email addresses on file for the members to remove.
3. `DELETE /members/bulk-remove` with the JSON body.
4. On `200 OK`, compare `removedCount` against the number of emails sent. A shortfall means one or more emails didn't match a member, or matched an admin.
5. If you need to know *which* emails didn't take effect, re-query the members (`membersByProduct` / `memberById` in GraphQL) and diff against your list — the response body deliberately carries only a count.

### Recipes

#### Recipe 1: offboard a batch of members from Recruit

```json
{
  "product": "RECRUIT",
  "emails": [
    "jane.smith@firm.com",
    "john.doe@firm.com",
    "sam.lee@firm.com"
  ]
}
```

Each of the three loses Recruit access. Any of them that is a Recruit admin, or whose email doesn't match a member, is skipped.

#### Recipe 2: remove Perform access but keep Recruit access

```json
{
  "product": "PERFORM",
  "emails": ["jane.smith@firm.com"]
}
```

Requires the Perform Admin role. Jane keeps her Recruit access and her member record; only the Perform role is removed.

#### Recipe 3: dry-run-ish validation before a large batch

There is no dry-run mode. Send a single-email request for one representative member first, confirm `removedCount` is `1`, then submit the rest of the batch.

### Things to be careful about

* **This is not a member deletion.** The member stays in your org with their other product access intact.
* **Admins are skipped silently.** No error, no per-email report. Watch `removedCount`.
* **Email matching is exact and unnormalized.** A trailing space, a different alias, or a stale address on your side produces a silent no-op for that entry.
* **Some HTTP clients drop request bodies on `DELETE`.** A few libraries and proxies strip the body from a `DELETE`, which arrives as an empty email list and returns `removedCount: 0`. If you get an unexplained zero, confirm your client actually sent the body.
* **There is no dry-run mode and no undo.** Re-granting access requires `import-csv`, and re-granting fires a welcome email.
* **The feature flag gate is per-org.** A call that works for one org can return `403` for another if the flag isn't enabled there.

### Example: curl

```bash
curl -X DELETE 'https://<host>/api/v2/members/bulk-remove' \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
        "product": "RECRUIT",
        "emails": ["jane.smith@firm.com", "john.doe@firm.com"]
      }'
```

Response:

```json
{ "removedCount": 2 }
```

### Common pitfalls

| Symptom                                                | Likely cause                                                                                                                       |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `removedCount` is lower than the number of emails sent | One or more emails matched no member in your org, or matched a member whose role for that product is `ADMIN` (admins are skipped). |
| `removedCount: 0` on a request you know is correct     | The request body didn't arrive — some HTTP clients strip bodies from `DELETE`. Verify with a raw curl.                             |
| `403 Forbidden` on every call                          | The `user-list-members-bulk-delete` flag isn't enabled for your org, or you're targeting `PERFORM` without the Perform Admin role. |
| An admin wasn't removed                                | By design. Demote to `MEMBER` first, then re-run.                                                                                  |
| The member still appears in your other integrations    | Only the one product's access was removed. The member record itself still exists — use `deleteMember` for a full deletion.         |
| Member removed from the wrong product                  | `product` is in the request body, not a query parameter as it is on `import-csv`. Double-check the body field.                     |


# Matter API


# 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`.


# Matter Csv Controller

## Uploads matters from the csv file

> Use POST /matters-csv/upsert instead for idempotent behavior. This endpoint silently skips rows that already exist in the review cycle. Creates new matter for each row in the csv file. Rejects all rows if any single row contain errors.

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo production API"}],"paths":{"/matters-csv/upload-csv":{"post":{"deprecated":true,"description":"Use POST /matters-csv/upsert instead for idempotent behavior. This endpoint silently skips rows that already exist in the review cycle. Creates new matter for each row in the csv file. Rejects all rows if any single row contain errors.","operationId":"uploadMattersCSV","parameters":[{"description":"The UUID of the review cycle to be associated with the uploaded matters.","in":"query","name":"reviewCycleId","required":true,"schema":{"type":"string","format":"uuid","description":"The UUID of the review cycle to be associated with the uploaded matters."}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Comma-separated value file. Must be less than 10MB in size.\nHeader row is required, and each subsequent row corresponds to one matter entry.\nHeader row must match: \"Matter ID,Matter Name,Attorney Email,Billing Attorney Email,Start Date,End Date,Hours Billed,Client Name,Description\"\nThe columns must follow these requirements:\n- 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.\n- Matter Name: String, Required. Must be <= 255 characters. A Client Matter field: must be identical across all rows sharing the same (Matter ID, Client Name).\n- Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Billing Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Start Date: Date, Optional. When present, format must be M/d/yyyy, e.g. 6/30/2025. Blank persists as null on the entry.\n- 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 on the entry.\n- Hours Billed: Integer or Float, Required. Must be between 0 and 999999.99.\n- Client Name: String, Required. Must be <= 255 characters. Together with Matter ID, identifies the Client Matter this entry rolls up to.\n- Description: String, Optional. Must be <= 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).\n"}},"required":["file"]}}}},"responses":{"200":{"description":"Matters created successfully (no response body)"},"400":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Invalid CSV file format, or CSV file contains one or more invalid rows. Use /validate-csv for row level error details."},"401":{"description":"Unauthorized"},"403":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Forbidden"},"404":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Review Cycle not found"},"409":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Uploads matters from the csv file","tags":["matter-csv-controller"]}}}}
```

## Upserts matter entries from a CSV file

> Upserts a matter entry for each row in the CSV file. Uniqueness of a\
> matter entry within a review cycle is by the combined (Matter ID,\
> Client Name, Attorney Email) key.\
> \
> CSV formatting:\
> \- Field values containing a comma, double quote, or newline must be\
> &#x20; wrapped in double quotes. Embedded double quotes are escaped by\
> &#x20; doubling. Example: a Description of Smith, Jones & Associates\
> &#x20; must be written as "Smith, Jones & Associates".\
> \
> Behavior:\
> \- If a matter entry with that composite key already exists in the cycle,\
> &#x20; its non-key fields are updated in place when they differ from the row.\
> &#x20; Rows whose non-key fields already match the stored entry are no-ops\
> &#x20; and are not counted in the response's updated total. The row's id is\
> &#x20; preserved, so any existing review-cycle assignments referencing the\
> &#x20; entry survive.\
> \- If no such entry exists, a new one is created.\
> \- Multiple entries under the same Matter ID + Client Name (different\
> &#x20; Attorney Emails) are supported - they share a single Client Matter\
> &#x20; parent whose canonical Matter Name and Description also refresh on\
> &#x20; each upsert.\
> \
> Idempotency:\
> \- Uploading the same CSV twice produces the same end state - safe to retry.\
> \- Non-key fields updated: Matter Name, Billing Attorney Email, Start Date,\
> &#x20; End Date, Hours Billed, Description.\
> \- Matter Name and Description also refresh on the shared Client Matter\
> &#x20; parent; all existing entries under the same Matter ID + Client Name see\
> &#x20; the update.\
> \
> Validation:\
> \- All rows must pass validation or the entire upload is rejected (no\
> &#x20; partial commits).\
> \- Rows are rejected when:\
> &#x20;   \- Any field-level validation fails (see per-column rules below).\
> &#x20;   \- Two rows share the same upsert key (Matter ID, Client Name, Attorney\
> &#x20;     Email). Each matter entry must appear in the CSV at most once.\
> &#x20;   \- Two rows share the same Client Matter (Matter ID, Client Name) but\
> &#x20;     disagree on any Client Matter field (Matter Name or Description).\
> \- Pre-flight validation is available with POST /matters-csv/validate-upsert;\
> &#x20; it returns per-row, per-column error details without committing.\
> \
> Limits:\
> \- File size <= 10 MB.\
> \- Row count <= 10 000.<br>

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo production API"}],"paths":{"/matters-csv/upsert":{"post":{"description":"Upserts a matter entry for each row in the CSV file. Uniqueness of a\nmatter entry within a review cycle is by the combined (Matter ID,\nClient Name, Attorney Email) key.\n\nCSV formatting:\n- Field values containing a comma, double quote, or newline must be\n  wrapped in double quotes. Embedded double quotes are escaped by\n  doubling. Example: a Description of Smith, Jones & Associates\n  must be written as \"Smith, Jones & Associates\".\n\nBehavior:\n- If a matter entry with that composite key already exists in the cycle,\n  its non-key fields are updated in place when they differ from the row.\n  Rows whose non-key fields already match the stored entry are no-ops\n  and are not counted in the response's updated total. The row's id is\n  preserved, so any existing review-cycle assignments referencing the\n  entry survive.\n- If no such entry exists, a new one is created.\n- Multiple entries under the same Matter ID + Client Name (different\n  Attorney Emails) are supported - they share a single Client Matter\n  parent whose canonical Matter Name and Description also refresh on\n  each upsert.\n\nIdempotency:\n- Uploading the same CSV twice produces the same end state - safe to retry.\n- Non-key fields updated: Matter Name, Billing Attorney Email, Start Date,\n  End Date, Hours Billed, Description.\n- Matter Name and Description also refresh on the shared Client Matter\n  parent; all existing entries under the same Matter ID + Client Name see\n  the update.\n\nValidation:\n- All rows must pass validation or the entire upload is rejected (no\n  partial commits).\n- Rows are rejected when:\n    - Any field-level validation fails (see per-column rules below).\n    - Two rows share the same upsert key (Matter ID, Client Name, Attorney\n      Email). Each matter entry must appear in the CSV at most once.\n    - Two rows share the same Client Matter (Matter ID, Client Name) but\n      disagree on any Client Matter field (Matter Name or Description).\n- Pre-flight validation is available with POST /matters-csv/validate-upsert;\n  it returns per-row, per-column error details without committing.\n\nLimits:\n- File size <= 10 MB.\n- Row count <= 10 000.\n","operationId":"upsertMattersCSV","parameters":[{"description":"The UUID of the review cycle to associate with the upserted matter entries.","in":"query","name":"reviewCycleId","required":true,"schema":{"type":"string","format":"uuid","description":"The UUID of the review cycle to associate with the upserted matter entries."}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Comma-separated value file. Must be less than 10MB in size.\nHeader row is required, and each subsequent row corresponds to one matter entry.\nHeader row must match: \"Matter ID,Matter Name,Attorney Email,Billing Attorney Email,Start Date,End Date,Hours Billed,Client Name,Description\"\nThe columns must follow these requirements:\n- 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.\n- Matter Name: String, Required. Must be <= 255 characters. A Client Matter field: must be identical across all rows sharing the same (Matter ID, Client Name).\n- Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Billing Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Start Date: Date, Optional. When present, format must be M/d/yyyy, e.g. 6/30/2025. Blank persists as null on the entry.\n- 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 on the entry.\n- Hours Billed: Integer or Float, Required. Must be between 0 and 999999.99.\n- Client Name: String, Required. Must be <= 255 characters. Together with Matter ID, identifies the Client Matter this entry rolls up to.\n- Description: String, Optional. Must be <= 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).\n"}},"required":["file"]}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MattersCSVUpsertResponse"}}},"description":"Matter entries upserted successfully. Response body reports created/updated counts."},"400":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"The CSV was rejected. Causes:\n- Unsupported file format or empty file.\n- File exceeds 10 MB or contains more than 10 000 rows.\n- One or more rows failed field-level validation.\n- Two rows share the same upsert key (Matter ID, Client Name,\n  Attorney Email).\n- Two rows share the same Client Matter (Matter ID, Client Name)\n  but disagree on any Client Matter field (Matter Name, Description).\nUse POST /matters-csv/validate-upsert to identify the specific rows\nand columns with errors."},"401":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Unauthorized"},"403":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Forbidden"},"404":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Review Cycle not found for the given reviewCycleId."},"409":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Upserts matter entries from a CSV file","tags":["matter-csv-controller"]}}},"components":{"schemas":{"MattersCSVUpsertResponse":{"type":"object","description":"Counts from a matter entry CSV upsert.","properties":{"created":{"type":"integer","format":"int32","description":"Number of matter entries newly created by this upsert."},"updated":{"type":"integer","format":"int32","description":"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."}},"required":["created","updated"]}}}}
```

## Validates matters csv file

> Use POST /matters-csv/validate-upsert instead. This endpoint validates against the /upload-csv rules and does not flag duplicate upsert keys within a file. Returns row and column level errors for matters in csv file. Only performs validation and does not upload the file. Email errors occur if no members exist with the specified email.

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo production API"}],"paths":{"/matters-csv/validate-csv":{"post":{"deprecated":true,"description":"Use POST /matters-csv/validate-upsert instead. This endpoint validates against the /upload-csv rules and does not flag duplicate upsert keys within a file. Returns row and column level errors for matters in csv file. Only performs validation and does not upload the file. Email errors occur if no members exist with the specified email.","operationId":"validateMattersCSV","requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Comma-separated value file. Must be less than 10MB in size.\nHeader row is required, and each subsequent row corresponds to one matter entry.\nHeader row must match: \"Matter ID,Matter Name,Attorney Email,Billing Attorney Email,Start Date,End Date,Hours Billed,Client Name,Description\"\nThe columns must follow these requirements:\n- 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.\n- Matter Name: String, Required. Must be <= 255 characters. A Client Matter field: must be identical across all rows sharing the same (Matter ID, Client Name).\n- Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Billing Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Start Date: Date, Optional. When present, format must be M/d/yyyy, e.g. 6/30/2025. Blank persists as null on the entry.\n- 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 on the entry.\n- Hours Billed: Integer or Float, Required. Must be between 0 and 999999.99.\n- Client Name: String, Required. Must be <= 255 characters. Together with Matter ID, identifies the Client Matter this entry rolls up to.\n- Description: String, Optional. Must be <= 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).\n"}},"required":["file"]}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MattersCSVValidationResponse"}}},"description":"Validation performed. Response includes rows numbers and column names that contain errors. Email errors occur if no members exist with the specified email."},"400":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Bad Request"},"401":{"content":{"*/*":{"schema":{"$ref":"#/components/schemas/MattersCSVValidationResponse"}}},"description":"Unauthorized"},"403":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Forbidden"},"404":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Not Found"},"409":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Validates matters csv file","tags":["matter-csv-controller"]}}},"components":{"schemas":{"MattersCSVValidationResponse":{"type":"object","properties":{"fileError":{"$ref":"#/components/schemas/MatterCSVFileError","description":"Present only when the whole file was rejected before per-row validation; null when the file parsed."},"numValidRows":{"type":"integer","format":"int32","description":"The number of valid rows in the CSV file."},"rowErrors":{"type":"array","description":"Details for each row containing errors. Empty if no rows have errors.","items":{"$ref":"#/components/schemas/MatterCSVRowErrors"}}}},"MatterCSVFileError":{"type":"object","properties":{"description":{"type":"string","description":"A human-readable explanation of the rejection, with enough detail to correct the file."},"rowNumber":{"type":"integer","format":"int32","description":"The file row that could not be parsed (the header is row 1). Populated only for COLUMN_COUNT_MISMATCH."},"type":{"type":"string","description":"The kind of file-level rejection.","enum":["INVALID_FILE_SIZE","INVALID_ROW_COUNT","COLUMN_COUNT_MISMATCH","UNREADABLE_FILE"]}},"required":["description","type"]},"MatterCSVRowErrors":{"type":"object","properties":{"columnsWithErrors":{"type":"array","description":"The column(s) that failed validation. Emails will fail validation if the addresses are not associated with existing perform member accounts.","items":{"type":"string"},"uniqueItems":true},"rowNumber":{"type":"integer","format":"int32","description":"The row number for the error."}}}}}}
```

## Pre-flight-validates a matter entry CSV for the upsert endpoint

> Validates the CSV against the same rules POST /matters-csv/upsert enforces.\
> Returns per-row, per-column error details. Does not persist any data.\
> Uniqueness of a matter entry within a review cycle is by the combined\
> (Matter ID, Client Name, Attorney Email) key.<br>

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo production API"}],"paths":{"/matters-csv/validate-upsert":{"post":{"description":"Validates the CSV against the same rules POST /matters-csv/upsert enforces.\nReturns per-row, per-column error details. Does not persist any data.\nUniqueness of a matter entry within a review cycle is by the combined\n(Matter ID, Client Name, Attorney Email) key.\n","operationId":"validateUpsertCSV","requestBody":{"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Comma-separated value file. Must be less than 10MB in size.\nHeader row is required, and each subsequent row corresponds to one matter entry.\nHeader row must match: \"Matter ID,Matter Name,Attorney Email,Billing Attorney Email,Start Date,End Date,Hours Billed,Client Name,Description\"\nThe columns must follow these requirements:\n- 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.\n- Matter Name: String, Required. Must be <= 255 characters. A Client Matter field: must be identical across all rows sharing the same (Matter ID, Client Name).\n- Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Billing Attorney Email: String, Required. Email must match an existing Perform member's email.\n- Start Date: Date, Optional. When present, format must be M/d/yyyy, e.g. 6/30/2025. Blank persists as null on the entry.\n- 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 on the entry.\n- Hours Billed: Integer or Float, Required. Must be between 0 and 999999.99.\n- Client Name: String, Required. Must be <= 255 characters. Together with Matter ID, identifies the Client Matter this entry rolls up to.\n- Description: String, Optional. Must be <= 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).\n"}},"required":["file"]}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MattersCSVValidationResponse"}}},"description":"Validation performed. Response includes row numbers and column names that contain errors."},"400":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Bad Request"},"401":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Unauthorized"},"403":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Forbidden"},"404":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Not Found"},"409":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Conflict"},"500":{"content":{"*/*":{"schema":{"type":"string"}}},"description":"Internal Server Error"}},"summary":"Pre-flight-validates a matter entry CSV for the upsert endpoint","tags":["matter-csv-controller"]}}},"components":{"schemas":{"MattersCSVValidationResponse":{"type":"object","properties":{"fileError":{"$ref":"#/components/schemas/MatterCSVFileError","description":"Present only when the whole file was rejected before per-row validation; null when the file parsed."},"numValidRows":{"type":"integer","format":"int32","description":"The number of valid rows in the CSV file."},"rowErrors":{"type":"array","description":"Details for each row containing errors. Empty if no rows have errors.","items":{"$ref":"#/components/schemas/MatterCSVRowErrors"}}}},"MatterCSVFileError":{"type":"object","properties":{"description":{"type":"string","description":"A human-readable explanation of the rejection, with enough detail to correct the file."},"rowNumber":{"type":"integer","format":"int32","description":"The file row that could not be parsed (the header is row 1). Populated only for COLUMN_COUNT_MISMATCH."},"type":{"type":"string","description":"The kind of file-level rejection.","enum":["INVALID_FILE_SIZE","INVALID_ROW_COUNT","COLUMN_COUNT_MISMATCH","UNREADABLE_FILE"]}},"required":["description","type"]},"MatterCSVRowErrors":{"type":"object","properties":{"columnsWithErrors":{"type":"array","description":"The column(s) that failed validation. Emails will fail validation if the addresses are not associated with existing perform member accounts.","items":{"type":"string"},"uniqueItems":true},"rowNumber":{"type":"integer","format":"int32","description":"The row number for the error."}}}}}}
```


# Models

## The MatterCSVFileError object

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"components":{"schemas":{"MatterCSVFileError":{"type":"object","properties":{"description":{"type":"string","description":"A human-readable explanation of the rejection, with enough detail to correct the file."},"rowNumber":{"type":"integer","format":"int32","description":"The file row that could not be parsed (the header is row 1). Populated only for COLUMN_COUNT_MISMATCH."},"type":{"type":"string","description":"The kind of file-level rejection.","enum":["INVALID_FILE_SIZE","INVALID_ROW_COUNT","COLUMN_COUNT_MISMATCH","UNREADABLE_FILE"]}},"required":["description","type"]}}}}
```

## The MatterCSVRowErrors object

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"components":{"schemas":{"MatterCSVRowErrors":{"type":"object","properties":{"columnsWithErrors":{"type":"array","description":"The column(s) that failed validation. Emails will fail validation if the addresses are not associated with existing perform member accounts.","items":{"type":"string"},"uniqueItems":true},"rowNumber":{"type":"integer","format":"int32","description":"The row number for the error."}}}}}}
```

## The MattersCSVUpsertResponse object

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"components":{"schemas":{"MattersCSVUpsertResponse":{"type":"object","description":"Counts from a matter entry CSV upsert.","properties":{"created":{"type":"integer","format":"int32","description":"Number of matter entries newly created by this upsert."},"updated":{"type":"integer","format":"int32","description":"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."}},"required":["created","updated"]}}}}
```

## The MattersCSVValidationResponse object

```json
{"openapi":"3.1.0","info":{"title":"Flo Web API — Matters","version":"2.0.0"},"components":{"schemas":{"MattersCSVValidationResponse":{"type":"object","properties":{"fileError":{"$ref":"#/components/schemas/MatterCSVFileError","description":"Present only when the whole file was rejected before per-row validation; null when the file parsed."},"numValidRows":{"type":"integer","format":"int32","description":"The number of valid rows in the CSV file."},"rowErrors":{"type":"array","description":"Details for each row containing errors. Empty if no rows have errors.","items":{"$ref":"#/components/schemas/MatterCSVRowErrors"}}}},"MatterCSVFileError":{"type":"object","properties":{"description":{"type":"string","description":"A human-readable explanation of the rejection, with enough detail to correct the file."},"rowNumber":{"type":"integer","format":"int32","description":"The file row that could not be parsed (the header is row 1). Populated only for COLUMN_COUNT_MISMATCH."},"type":{"type":"string","description":"The kind of file-level rejection.","enum":["INVALID_FILE_SIZE","INVALID_ROW_COUNT","COLUMN_COUNT_MISMATCH","UNREADABLE_FILE"]}},"required":["description","type"]},"MatterCSVRowErrors":{"type":"object","properties":{"columnsWithErrors":{"type":"array","description":"The column(s) that failed validation. Emails will fail validation if the addresses are not associated with existing perform member accounts.","items":{"type":"string"},"uniqueItems":true},"rowNumber":{"type":"integer","format":"int32","description":"The row number for the error."}}}}}}
```


# HRIS API

## Retrieve job applications with optional filters

> Fetch job applications, optionally filtered by last-status-update date\
> range and/or pipeline stage.\
> \
> \- \`job\_application\_status\_start\` / \`job\_application\_status\_end\` bound the\
> &#x20; last status-update timestamp (UTC). If \`\_start\` is omitted it defaults\
> &#x20; to 30 days in the past.\
> \- \`job\_application\_status\` restricts results to a single pipeline stage.\
> &#x20; When omitted, all statuses within the date range are returned.<br>

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit HRIS API — Job Applications","version":"1.0.0"},"servers":[{"url":"https://api.florecruit.com","description":"Flo Recruit production API"}],"paths":{"/v1/job-applications":{"get":{"tags":["HRIS API"],"summary":"Retrieve job applications with optional filters","description":"Fetch job applications, optionally filtered by last-status-update date\nrange and/or pipeline stage.\n\n- `job_application_status_start` / `job_application_status_end` bound the\n  last status-update timestamp (UTC). If `_start` is omitted it defaults\n  to 30 days in the past.\n- `job_application_status` restricts results to a single pipeline stage.\n  When omitted, all statuses within the date range are returned.\n","operationId":"fetchHRISJobApplications","parameters":[{"name":"job_application_status_start","in":"query","description":"Filter by last status-update start date (UTC). Defaults to 30 days in the past.","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"job_application_status_end","in":"query","description":"Filter by last status-update end date (UTC).","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"job_application_status","in":"query","description":"Filter by pipeline stage (e.g. Application Extended, Offer Accepted). Must be a step in an Active Pipeline Template.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK — successfully retrieved job applications matching the filter criteria.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JobApplicationHRISResponse"}}}}},"400":{"description":"Bad Request — malformed query parameters or invalid status-filter value. Check date-time format and pipeline stage names.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized — authentication failed or session missing. Contact Flo Recruit Support for access issues.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too Many Requests — rate limit exceeded. Retry after a short delay.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal Server Error — an unexpected error occurred on the server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"JobApplicationHRISResponse":{"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"candidateId":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"applicationId":{"type":"string","format":"uuid"},"jobTitle":{"type":"string"},"jobId":{"type":"string","format":"uuid"},"department":{"type":"string"},"officeLocation":{"type":"string"},"employmentType":{"type":"string"},"jobApplicationStatuses":{"type":"array","items":{"$ref":"#/components/schemas/JobApplicationStatus"}},"position":{"type":"string"},"requisitionId":{"type":"string"},"startDate":{"type":"string","format":"date"},"salary":{"type":"integer"},"jobApplicationLastStatusUpdate":{"type":"string","format":"date-time"}}},"JobApplicationStatus":{"type":"object","properties":{"statusName":{"type":"string"},"statusUpdatedAt":{"type":"string","format":"date-time"}}},"Error":{"type":"object","properties":{"error":{"type":"string"}}}}}}
```


# Models

## The JobApplicationHRISResponse object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit HRIS API — Job Applications","version":"1.0.0"},"components":{"schemas":{"JobApplicationHRISResponse":{"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"candidateId":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"applicationId":{"type":"string","format":"uuid"},"jobTitle":{"type":"string"},"jobId":{"type":"string","format":"uuid"},"department":{"type":"string"},"officeLocation":{"type":"string"},"employmentType":{"type":"string"},"jobApplicationStatuses":{"type":"array","items":{"$ref":"#/components/schemas/JobApplicationStatus"}},"position":{"type":"string"},"requisitionId":{"type":"string"},"startDate":{"type":"string","format":"date"},"salary":{"type":"integer"},"jobApplicationLastStatusUpdate":{"type":"string","format":"date-time"}}},"JobApplicationStatus":{"type":"object","properties":{"statusName":{"type":"string"},"statusUpdatedAt":{"type":"string","format":"date-time"}}}}}}
```

## The JobApplicationStatus object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit HRIS API — Job Applications","version":"1.0.0"},"components":{"schemas":{"JobApplicationStatus":{"type":"object","properties":{"statusName":{"type":"string"},"statusUpdatedAt":{"type":"string","format":"date-time"}}}}}}
```

## The Error object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit HRIS API — Job Applications","version":"1.0.0"},"components":{"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}}}}}}
```


# Members API

## Import new members into the given product

> Creates new members from CSV input for the target \`product\`. Returns\
> \`200\` with no body on success, or a detailed validation result on \`400\`.<br>

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Members Import CSV","version":"1.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo Recruit production API"}],"paths":{"/members/import-csv":{"post":{"tags":["members-API"],"summary":"Import new members into the given product","description":"Creates new members from CSV input for the target `product`. Returns\n`200` with no body on success, or a detailed validation result on `400`.\n","operationId":"importProductMemberCsv","parameters":[{"name":"product","in":"query","required":true,"description":"Target product to import members into.","schema":{"type":"string","enum":["RECRUIT","PERFORM","UNI"]}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"CSV file containing member records."}}}}}},"responses":{"200":{"description":"Members created successfully (no response body)."},"400":{"description":"Invalid member data — file-level and/or row/column-level validation errors.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MembersCsvValidationResult"}}}},"401":{"description":"Unauthorized — authentication failed or session missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Forbidden — the session is not permitted to import members into this product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"MembersCsvValidationResult":{"type":"object","properties":{"fileError":{"$ref":"#/components/schemas/FileError"},"rowColumnErrors":{"type":"array","items":{"$ref":"#/components/schemas/RowColumnError"}}}},"FileError":{"type":"object","nullable":true,"properties":{"message":{"type":"string"},"type":{"type":"string","enum":["EMPTY_FILE","INVALID_FILE_FORMAT","MISSING_REQUIRED_COLUMNS","UNEXPECTED_COLUMNS","FILE_SIZE_EXCEEDED"]}}},"RowColumnError":{"type":"object","properties":{"columnName":{"type":"string"},"message":{"type":"string"},"rowNumber":{"type":"integer","format":"int32"},"type":{"type":"string","enum":["EMPTY_REQUIRED_VALUE","INVALID_EMAIL","INVALID_FORMAT","INVALID_URL","INVALID_YEAR","INVALID_DATE_FORMAT","DUPLICATE_VALUE","EXISTING_USER_CONFLICT","INVALID_LIST_SELECTION","INVALID_SSO_ID","ROW_VALUE_CONFLICT","UNKNOWN"]},"value":{"type":"string"}}},"Error":{"type":"object","properties":{"error":{"type":"string"}}}}}}
```


# Models

## The MembersCsvValidationResult object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Members Import CSV","version":"1.0.0"},"components":{"schemas":{"MembersCsvValidationResult":{"type":"object","properties":{"fileError":{"$ref":"#/components/schemas/FileError"},"rowColumnErrors":{"type":"array","items":{"$ref":"#/components/schemas/RowColumnError"}}}},"FileError":{"type":"object","nullable":true,"properties":{"message":{"type":"string"},"type":{"type":"string","enum":["EMPTY_FILE","INVALID_FILE_FORMAT","MISSING_REQUIRED_COLUMNS","UNEXPECTED_COLUMNS","FILE_SIZE_EXCEEDED"]}}},"RowColumnError":{"type":"object","properties":{"columnName":{"type":"string"},"message":{"type":"string"},"rowNumber":{"type":"integer","format":"int32"},"type":{"type":"string","enum":["EMPTY_REQUIRED_VALUE","INVALID_EMAIL","INVALID_FORMAT","INVALID_URL","INVALID_YEAR","INVALID_DATE_FORMAT","DUPLICATE_VALUE","EXISTING_USER_CONFLICT","INVALID_LIST_SELECTION","INVALID_SSO_ID","ROW_VALUE_CONFLICT","UNKNOWN"]},"value":{"type":"string"}}}}}}
```

## The FileError object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Members Import CSV","version":"1.0.0"},"components":{"schemas":{"FileError":{"type":"object","nullable":true,"properties":{"message":{"type":"string"},"type":{"type":"string","enum":["EMPTY_FILE","INVALID_FILE_FORMAT","MISSING_REQUIRED_COLUMNS","UNEXPECTED_COLUMNS","FILE_SIZE_EXCEEDED"]}}}}}}
```

## The RowColumnError object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Members Import CSV","version":"1.0.0"},"components":{"schemas":{"RowColumnError":{"type":"object","properties":{"columnName":{"type":"string"},"message":{"type":"string"},"rowNumber":{"type":"integer","format":"int32"},"type":{"type":"string","enum":["EMPTY_REQUIRED_VALUE","INVALID_EMAIL","INVALID_FORMAT","INVALID_URL","INVALID_YEAR","INVALID_DATE_FORMAT","DUPLICATE_VALUE","EXISTING_USER_CONFLICT","INVALID_LIST_SELECTION","INVALID_SSO_ID","ROW_VALUE_CONFLICT","UNKNOWN"]},"value":{"type":"string"}}}}}}
```

## The Error object

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Members Import CSV","version":"1.0.0"},"components":{"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}}}}}}
```


# Matters API

## Upload matters CSV for a review cycle

> Accepts a CSV file upload and associates it with the provided review\
> cycle. Returns \`200 OK\` on success.<br>

```json
{"openapi":"3.0.3","info":{"title":"Flo Recruit Web API — Matters CSV Upload","version":"1.0.0"},"servers":[{"url":"https://florecruit.com/api/v2","description":"Flo Recruit production API"}],"paths":{"/matters-csv/upload-csv":{"post":{"tags":["matters-API"],"summary":"Upload matters CSV for a review cycle","description":"Accepts a CSV file upload and associates it with the provided review\ncycle. Returns `200 OK` on success.\n","operationId":"uploadMattersCSV","parameters":[{"name":"reviewCycleId","in":"query","required":true,"description":"Review cycle identifier the uploaded matters belong to.","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"CSV file containing matters data."}}}}}},"responses":{"200":{"description":"OK — matters CSV accepted and associated with the review cycle."},"400":{"description":"Bad Request — malformed CSV or invalid review cycle.","content":{"*/*":{"schema":{"type":"string"}}}},"404":{"description":"Not Found — no review cycle exists for the given reviewCycleId.","content":{"*/*":{"schema":{"type":"string"}}}},"500":{"description":"Internal Server Error — an unexpected error occurred on the server.","content":{"*/*":{"schema":{"type":"string"}}}}}}}}}
```


# Models


# Change Log

## \[1.1.0] - 2025-12-28

#### Added

* [User API](#user-api)
* [Matters API](#matters-api)
* We have added "customFields" to the `job-applications` response

#### Matters API

Flo API users now have the ability to upload matter data via our API. &#x20;

#### User API

Flo API users now have the ability to upload users via our API

#### HRIS API Response Change Diff

```json
[
  {
    "firstName": "Jane",
    "lastName": "Doe",
    ...
    "customFields": [
      {
        "customFieldTitle": "Years of Experience",
        "customFieldAnswer": ["5"]
      },
      {
        "customFieldTitle": "Preferred Practice Areas",
        "customFieldAnswer": ["Litigation", "Corporate Law", "Employment Law"]
      },
      {
        "customFieldTitle": "Bar Admission Date",
        "customFieldAnswer": ["2020-06-15"]
      },
      {
        "customFieldTitle": "Additional Notes",
        "customFieldAnswer": null
      }
    ]
...
}
```

## \[1.1.0] - 2025-12-03

#### Added

* We have added 5 new fields to the `job-applications` response:
  * `addressLine1` (string): Candidate address line 1 for job applications.
  * `addressLine2` (string): Candidate address line 2 for job applications.
  * `state` (string): Candidate state for job applications.
  * `city` (string): Candidate city for job applications.
  * `zipCode` (string): Candidate zip code for job applications.

#### API Response Change Diff

```json
[
  {
    "firstName": "Jane",
    "lastName": "Doe",
    ...
    "startDate": "2025-10-10",
    "salary": 175000,
+   "addressLine1": "1428 Elm Street",
+   "addressLine2": "Apartment 101",
+   "city": "Springfield",
+   "state": "Illinois",
+   "zipCode": "62704",
    "jobApplicationStatuses": [
      {
        "statusName": "Application Received",
        "statusBucketName": "Applied",
        "statusUpdatedAt": "2025-10-01T22:07:44Z"
      },
...
}
```

#### Changed

* None.

#### Deprecated

* None.

#### Removed

* None.

#### Fixed

* None.

#### Security

* None.

## \[1.0.0] - 2025-11-05

#### Added

* Initial release of the Flo Recruit Public API.
* New endpoint for authenticating into Flo Platform

  ```
  https://florecruit.com/app/{org_name}/admin/auth/ 
  ```
* New endpoint added for Job Application Data&#x20;

  ```
  /v1/job-applications
  ```

#### Changed

* N/A (first release)

#### Deprecated

* None.

#### Removed

* None.

#### Fixed

* N/A (initial version)

#### Security

* Enforced TLS 1.2+ for all API traffic.


# Help Docs

[**https://help.florecruit.com/en/**](https://help.florecruit.com/en/)


