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

# 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' \
  -b 'FLOAUTH=<your session 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.                     |
