> ## Documentation Index
> Fetch the complete documentation index at: https://help.honestly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# POST /v1/employees/imports — Import Employees

> Push your full employee list to Honestly over the REST API. Replaces CSV over SFTP. Learn the snapshot model, the omitted/null/value rule, and the deletion threshold.

Send Honestly the employees you want it to hold. Each request is a complete snapshot of the employees this API manages: employees you send are created or updated, and employees this API created that you stop sending are offboarded. Employees added any other way are never touched — see [Offboarding](#offboarding).

This endpoint replaces the CSV-over-SFTP import. Because JSON carries structure per employee rather than one header row per file, you can decide field by field and employee by employee what an import should change — including leaving a field exactly as an admin configured it in the app.

<Note>
  This is the only write endpoint in the API. Every other endpoint in this reference is a read-only `GET`, and the API key you use for those will not work here — see [Authentication](#authentication) below.
</Note>

<Warning>
  This request can set each employee's role and data access, so the key you use here can change who administers your account. Treat it as a privileged credential: keep it in the system that owns your HR data, never share it with reporting or BI tools, and rotate it from the **Integrations** page if it is ever exposed.
</Warning>

## Endpoint

```text theme={null}
POST https://api.honestly.com/v1/employees/imports
```

## Authentication

Authenticate with your API key in the `X-Api-Key` header, as everywhere else in this API. The key must carry the `employees:write` scope.

Keys created on the **API keys** page are read-only and are rejected here with `403`. The key for this endpoint is issued separately, when you connect the **REST API** integration on the **Integrations** page.

## How an Import Is Applied

<Steps>
  <Step title="Honestly validates the request">
    The whole body is checked against the schema below. If anything is malformed you get a `400` and nothing is queued.
  </Step>

  <Step title="You get a 202 and an import id">
    A workforce import is far too much work for one HTTP request, so it runs in the background. The response confirms the import was accepted, not that it finished.
  </Step>

  <Step title="Honestly reconciles your account">
    Employees are created and updated one by one, supervisors are resolved once every employee has been applied, and anyone this API created but you no longer send is offboarded.
  </Step>
</Steps>

<Note>
  The result of an import appears on the **Integrations** page in the app, and account admins are emailed if any employee was skipped. There is no endpoint to poll for the outcome, so `import_uuid` is a reference for support rather than something to check.
</Note>

## Omitted, Null, and a Value

This is the part worth reading twice. Every optional property has three distinguishable states, and the difference between the first two is the difference between preserving a field and wiping it.

| You send               | Meaning                                                                                                                                                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The property is absent | Leave it unchanged. Honestly holds no opinion from this import, so whatever the value is today survives — including something an admin set in the app. |
| `"property": null`     | Clear it, or reset it to its default.                                                                                                                  |
| `"property": "value"`  | Overwrite it with what you sent.                                                                                                                       |

`attributes` follows the same rule per entry, with membership of the list standing in for the property being present:

```json theme={null}
"attributes": [
  { "name": "Location",    "value": null },
  { "name": "Cost Center", "value": { "name": "CC-4120" } }
]
```

`Location` is cleared, `Cost Center` is set, and any attribute not in the list — `Department`, say — is left exactly as it is. An empty list therefore means "I have nothing to say about this employee's attributes", not "remove them all".

<Warning>
  **Porting a CSV export?** A file that emits a fixed header and blanks for missing data is, in JSON terms, sending `null` everywhere. If you translate such an export literally you will clear fields the file was only ever silent about. Send a property only when your HR system actually has an opinion about it.
</Warning>

## Body Parameters

<ParamField body="employees" type="object[]" required>
  The complete set of employees for this API to hold. At least one. Employees created any other way are not part of this snapshot and are left untouched.

  There is deliberately no limit on how many employees you send: reconciliation is only sound when one request carries your whole workforce, so an account of any size sends a single request. The only ceiling is the request body size we accept. Payloads of this shape compress extremely well, since most of their bytes are repeated attribute names — send `Content-Encoding: gzip`.

  <Expandable title="employee">
    <ParamField body="import_id" type="string" required>
      Your stable identifier for this employee, from your HR system. Honestly matches employees on this, so it must be unique within the request and must not change between imports.
    </ParamField>

    <ParamField body="email" type="string" required>
      Must be unique within your account. If the address already belongs to a different employee, this one is skipped.
    </ParamField>

    <ParamField body="firstname" type="string" required>
      The employee's first name. Note there is no underscore in the property name — it matches the CSV header.
    </ParamField>

    <ParamField body="lastname" type="string" required>
      The employee's last name.
    </ParamField>

    <ParamField body="language" type="string">
      ISO 639-1 code, two letters. Falls back to the default language configured on the integration. An unsupported code skips this employee rather than failing the import.
    </ParamField>

    <ParamField body="reports_to_id" type="string | null">
      The `import_id` of this employee's supervisor. Resolved after every employee in the request has been applied, so the order you send them in does not matter. `null` removes the supervisor.
    </ParamField>

    <ParamField body="role" type="string | null">
      What this employee can do in Honestly. One of `Admin`, `Manager`, `Analyst`, `Report viewer` or `Respondee`. `null` resets them to `Respondee`, which clears their data-access grants with it.

      Anything else — including legacy spellings the CSV import tolerates — is refused and that employee is skipped.
    </ParamField>

    <ParamField body="permissions" type="object | null">
      Which data this employee may see in reports. `null` revokes every grant. Permissions only mean something for roles that can log in; for a `Respondee` they are ignored.

      <Expandable title="permissions">
        <ParamField body="all_data" type="boolean" default="false">
          Access to all data. When `true`, `grants` is ignored entirely.
        </ParamField>

        <ParamField body="grants" type="object[]">
          Scoped grants, each naming an attribute and one of its values. Send as many as you need — there is no limit, and they may name different attributes. An empty list with `all_data: false` revokes every grant.

          How several grants combine depends on whether they name the same attribute:

          * **Several values of one attribute widen the scope.** `Department: Sales` plus `Department: Marketing` lets this employee see both departments.
          * **Grants on different attributes narrow it.** `Department: Sales` plus `Location: Berlin` does not mean "all of Sales, plus all of Berlin" — it means the Berlin part of Sales only. Honestly intersects across attributes, so the employee sees the people who match every attribute you granted.

          ```json theme={null}
          "grants": [
            { "attribute": { "name": "Department" }, "value": { "name": "Sales" } },
            { "attribute": { "name": "Department" }, "value": { "name": "Marketing" } },
            { "attribute": { "name": "Location" },   "value": { "name": "Berlin" } }
          ]
          ```

          That grants Sales **or** Marketing, **and** Berlin — a Berlin-based lead over both teams.

          A grant naming an attribute or value that does not exist in your account is dropped, and the employee is imported regardless. Because attributes intersect, dropping one silently widens the scope rather than narrowing it — check the names against the [Attributes](/developers/api-reference/attributes) endpoint if a grant does not behave as you expect.
        </ParamField>
      </Expandable>
    </ParamField>

    <ParamField body="attributes" type="object[]">
      Employee attributes, each naming an attribute and the value assigned to it. See [Omitted, null, and a value](#omitted-null-and-a-value) for how membership of this list decides what changes.

      Attribute names are matched loosely — case and surrounding whitespace are normalised, so `age category` and `Age Category` are the same attribute. Listing the same attribute twice for one employee is refused and that employee is skipped.

      Attributes and attribute values that do not exist yet are created, so opening a Munich office and sending `Location` → `München` needs no preparation in the app.

      <Expandable title="attribute">
        <ParamField body="name" type="string" required>
          The attribute name as it appears in the app.
        </ParamField>

        <ParamField body="value" type="object | null" required>
          `null` clears the assignment. Omit the whole entry to leave it unchanged.

          <Expandable title="value">
            <ParamField body="name" type="string" required>
              The value assigned to the attribute.
            </ParamField>
          </Expandable>
        </ParamField>
      </Expandable>
    </ParamField>
  </Expandable>
</ParamField>

### Age Category and Seniority

Two attribute names are special, because Honestly stores a bucket rather than what you send. Send the year — not a date, and not the bucket.

| Attribute      | Send                                                        | Stored as                                         |
| -------------- | ----------------------------------------------------------- | ------------------------------------------------- |
| `Age Category` | The birth year, exactly four digits: `"1985"`               | `< 20`, `20-29`, `30-39`, `40-49`, `50-59`, `60+` |
| `Seniority`    | The year the employee joined, exactly four digits: `"2019"` | `< 5`, `05-09`, `10-14`, … `45+`                  |

The bucket is derived at import time, which means these two drift as people age and accrue tenure. Sending the same year on every import is correct and intended — it is what keeps the bucket current. A value that is not exactly four digits skips that employee.

## Offboarding

An employee this API created and you stop sending is offboarded on the next import. Employees created any other way — in the app, by a file upload, over SFTP, or by an HCM connector — are never touched by this endpoint.

To protect you against a broken export, an import that would offboard more than the **deletion threshold** configured on the integration is cancelled in full and changes nothing at all. The threshold defaults to 20% of the employees this API manages, and you can change it in the REST API dialog on the **Integrations** page. Raise it deliberately for a planned mass offboarding, then set it back.

The guard does not apply when nothing would be offboarded, so your first import into an empty account is never blocked.

## Example Request

```bash theme={null}
curl -X POST "https://api.honestly.com/v1/employees/imports" \
  -H "X-Api-Key: YOUR_SECRET_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @workforce.json
```

With `workforce.json`:

```json theme={null}
{
  "employees": [
    {
      "import_id": "HR-10045",
      "email": "marlene.faber@example.com",
      "firstname": "Marlene",
      "lastname": "Faber",
      "language": "de",
      "reports_to_id": "HR-10002",
      "role": "Manager",
      "permissions": {
        "all_data": false,
        "grants": [
          { "attribute": { "name": "Department" }, "value": { "name": "Customer Success" } }
        ]
      },
      "attributes": [
        { "name": "Department",   "value": { "name": "Customer Success" } },
        { "name": "Location",     "value": { "name": "Berlin" } },
        { "name": "Age Category", "value": { "name": "1988" } },
        { "name": "Seniority",    "value": { "name": "2019" } }
      ]
    },
    {
      "import_id": "HR-10046",
      "email": "tom.kessler@example.com",
      "firstname": "Tom",
      "lastname": "Kessler",
      "reports_to_id": "HR-10002",
      "attributes": [
        { "name": "Department", "value": { "name": "Customer Success" } }
      ]
    },
    {
      "import_id": "HR-10002",
      "email": "iris.nowak@example.com",
      "firstname": "Iris",
      "lastname": "Nowak",
      "reports_to_id": null,
      "role": null,
      "attributes": [
        { "name": "Department", "value": { "name": "Operations" } },
        { "name": "Location",   "value": null }
      ]
    }
  ]
}
```

The three employees exercise different parts of the contract. Marlene is a full record. Tom sends no `role` and no `permissions`, so whatever an admin configured for him in the app stays exactly as it is. Iris left the management track: her supervisor and role are reset, her `Location` is cleared, and her `Age Category` is untouched because it is not in her list.

## Example Response

```json theme={null}
{
  "import_uuid": "e665fc97-2ead-4800-9a7b-f531131acda4"
}
```

<ResponseField name="import_uuid" type="string">
  Identifier of the queued import, returned with `202 Accepted`. Keep it if you need to reference the import when contacting support.
</ResponseField>

## Errors

| Status code                 | Meaning                                                                                                           |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`           | The body is malformed — invalid JSON, an unknown property, a wrong type, or a missing required property.          |
| `401 Unauthorized`          | The `X-Api-Key` header is missing or the key is not valid.                                                        |
| `403 Forbidden`             | The key is valid but does not carry the `employees:write` scope. Read-only keys from the API keys page land here. |
| `404 Not Found`             | The REST API integration is not connected for this account.                                                       |
| `409 Conflict`              | An import for this account is still running. Wait for it to finish before sending another.                        |
| `500 Internal Server Error` | Something went wrong on our side.                                                                                 |

Errors use the same envelope as the rest of the API — see [Error handling](/developers/concepts/error-handling).

### When One Employee Is Refused

Some problems affect a single employee rather than the whole request. That employee is skipped, the rest of the import is applied, and account admins are emailed a summary. An employee is skipped when:

* their `role` is not one of the five accepted values
* their `language` is not a valid ISO 639-1 code
* their `Age Category` or `Seniority` is not exactly four digits
* they list the same attribute twice
* their `email` already belongs to somebody else

A skipped employee still counts as present, so reconciliation will not offboard them — they are simply left as they were.

Two problems cancel the entire import instead, because the result would otherwise depend on the order of your list: an employee with a missing or empty `import_id`, and the same `import_id` appearing twice.
