# List members

> Everyone in a company, one row per person across its teams.

Canonical page: https://www.rundiffusion.com/docs/api/members/list
Endpoint: GET /api/v2/members
Authorization: OAuth device flow or Company API Access Token

---

Everyone in a company, one row per **person**, with the teams they are on and
whether they hold a company role.

[List teams](/docs/api/teams/list) and [Get a team](/docs/api/teams/get) answer
per team, so somebody on four teams appears four times and you assemble the
person yourself. This endpoint has already done that: it groups by email address
and hands back one row carrying every team you are allowed to see them on.

`GET /api/v2/members`

Authorization: OAuth device flow or Company API Access Token

## Who sees what

This endpoint has no single permission gate. What it returns depends on who is
asking, and that shapes the response rather than refusing it:

| The caller | What comes back |
| --- | --- |
| A Company API Access Token, or `MANAGE_COMPANY` | Everyone in the company, including company-role holders who are on no team |
| `MANAGE_TEAMS` | Everyone on any team in the company |
| A member with neither permission | The people they share a team with, and only the shared teams |
| Someone who holds nothing in the company | `403 PERMISSION_DENIED` |

So a regular member gets a directory of the colleagues they work alongside, and
an administrator gets the whole organization, from the same request.

One consequence is worth building around: `membership_count` is counted
**within your scope**, not company-wide, so a colleague you share one team with
reports `membership_count: 1` even if they are on ten.

Note that scope is about *which* teams, never about whether they are activated.
That is [`is_active`](#deactivated-teams), and it applies to everyone alike.

`MANAGE_TEAMS` and `MANAGE_COMPANY` come from the caller's **company role**,
which is a separate grant from their team role. [Get identity](/docs/api/me)
returns it under `company_permissions`, not in the `permissions` map beside it.
See [Two roles, two maps](/docs/api/me#two-roles-two-maps).

## Request

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | string | Yes | Bearer token. This endpoint accepts an OAuth device flow token or a Company API Access Token. A Personal API Access Token is refused, because it reaches only its owner's own resources and is not scoped to a company. See [Authentication](/docs/api/authentication). |

**Query**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `company_id` | string | No | The company to read. Required with OAuth, which names a person rather than a company. A Company API Access Token already names its company, so it does not need this. [Get identity](/docs/api/me) returns it on every TEAM account. |
| `cursor` | string | No | Opaque pagination token. When a response has has_more true, pass its next_cursor here to get the following page. Do not construct or parse one. |
| `limit` | integer | No | People per page, from 1 to 100. Default: `24` |
| `sort` | string | No | Result order. Under joined, people who are still pending sort last in either direction, because they have no joined instant to order by. membership_count counts only the teams you can see, so it orders by your reading of each person. Ties always break on the email address, so two pages never overlap. Default: `email` One of: `email`, `joined`, `membership_count` |
| `order` | string | No | Which direction sort runs in. Default: `asc` One of: `asc`, `desc` |
| `q` | string | No | Case-insensitive substring match on the email address, up to 200 characters. Percent and underscore match themselves rather than acting as wildcards, so a search for a literal address behaves the way it reads. |
| `email` | string | No | An exact, case-insensitive match on the address, up to 320 characters. Use this to look one person up. q is a substring match and can return their neighbours as well. |
| `status` | string | No | How far along a person's invitations are. pending is someone who has accepted none of the ones you can see; joined is someone who has accepted at least one. Omit it for both. A company-role holder who is on no team is neither, so they come back only when you omit it. One of: `pending`, `joined` |
| `is_active` | boolean | No | Activation state of the teams a person is counted through. true for activated teams, false for deactivated ones, omitted for both, which is the default and matches List teams. A person is assembled only from the memberships this leaves, so their membership_teams, membership_count and status all follow it. |
| `in_team_id` | string | No | Only people with a membership on this team, accepted or pending. This is a filter, not an account selection, which is why it is not called team_id. A team outside what you can see matches nobody rather than returning an error. |

cURL:

```bash
curl "https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…&limit=25" \
  -H "Authorization: Bearer $RUNDIFFUSION_COMPANY_TOKEN"
```

JavaScript:

```javascript
const page = await fetch(
  'https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…&limit=25',
  { headers: { Authorization: `Bearer ${process.env.RUNDIFFUSION_COMPANY_TOKEN}` } },
).then(r => r.json());
```

Python:

```python
page = requests.get(
    f"{BASE}/members",
    params={"company_id": company_id, "limit": 25},
    headers=HEADERS,
).json()
```

### Three parameters that look alike

This endpoint is the one place three team-shaped names meet, and they answer
different questions:

| Parameter | Question it answers |
| --- | --- |
| `company_id` | Which company's people to read |
| `team_id` | Which account you are acting as |
| `in_team_id` | Which of those people to show |

`team_id` is the account selection every endpoint takes, and it is handled
before this one runs: the caller is resolved to that team's account and their
membership of it is checked. It does not narrow the result. Send the team you
are acting under, exactly as you would on any other call.

`in_team_id` is the filter. It has its own name precisely because `team_id` is
taken: a filter under that name would be read as an account selection, and an
administrator narrowing to a team they do not belong to would be refused at the
door rather than shown the team.

A **Company API Access Token** names its company already, so it needs neither
`company_id` nor `team_id`:

```bash
curl "https://api2.rundiffusion.com/api/v2/members" \
  -H "Authorization: Bearer $RUNDIFFUSION_COMPANY_TOKEN"
```

**OAuth** names a person, who may belong to more than one company, so name the
company:

```bash
curl "https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…" \
  -H "Authorization: Bearer $RUNDIFFUSION_TOKEN"
```

A **Personal API Access Token** is refused.

## Response

`200 OK` with one page of people, sorted by email address unless you asked
otherwise.

```json
{
  "data": [
    {
      "key": "sam@example.com",
      "email": "sam@example.com",
      "user_id": "k3PqV9…",
      "is_pending": false,
      "joined": "2026-03-14T09:04:52+00:00",
      "first_invited": "2026-03-14T09:03:40+00:00",
      "membership_teams": [
        { "id": "2D8NEx…", "name": "Design Studio", "pending": false, "is_active": true },
        { "id": "7QmWb2…", "name": "Archive 2025", "pending": false, "is_active": false }
      ],
      "membership_count": 2,
      "has_company_role": true,
      "invite_only_stub": false,
      "on_no_team": false
    },
    {
      "key": "new_hire@example.com",
      "email": "new_hire@example.com",
      "user_id": null,
      "is_pending": true,
      "joined": null,
      "first_invited": "2026-05-02T11:20:07+00:00",
      "membership_teams": [
        { "id": "2D8NEx…", "name": "Design Studio", "pending": true, "is_active": true }
      ],
      "membership_count": 1,
      "has_company_role": false,
      "invite_only_stub": false,
      "on_no_team": false
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

| Name | Type | Description |
| --- | --- | --- |
| `data` | array | The people on this page, one row each. Empty when nobody matches, which is not an error. |
| `next_cursor` | string \| null | Pass this back as cursor to fetch the next page. Null on the last page. |
| `has_more` | boolean | Whether more pages exist beyond this one. This is the flag to branch on when paging. |

### Member object

| Name | Type | Description |
| --- | --- | --- |
| `key` | string | The row's identity, which is the email address. Stable across teams, and the id of every membership record the person holds. Use it to key a list, not user_id, which a pending person does not have yet. |
| `email` | string | The email address the memberships belong to. |
| `user_id` | string \| null | The person's user ID, or null while none of the invitations you can see has been accepted. It is the id other endpoints report a generation under. |
| `is_pending` | boolean | True when every membership you can see is an outstanding invitation. Derived from the accepted-at instant rather than a separate flag, so it cannot disagree with joined. |
| `joined` | string (ISO 8601) \| null | The earliest accepted-at instant across the memberships you can see, in UTC, or null while is_pending is true. |
| `first_invited` | string (ISO 8601) \| null | When the earliest membership you can see was created, in UTC. It is present whether or not anything has been accepted, so it is how you find invitations that have been sitting unanswered. For a person on no team it is when their company role was granted. |
| `membership_teams` | array | The teams this person's memberships are on, each with id, name, pending and is_active. Only the teams you can see, which for an administrator is every team in the company and for everyone else is the teams they share with this person. |
| `membership_count` | integer | How many entries membership_teams has. Zero for a person on no team. |
| `has_company_role` | boolean | Whether the person holds a company role, which is a separate grant from any team role. The row does not name which role it is. |
| `invite_only_stub` | boolean | True when every membership you can see was created by granting a company role to an address with no account yet, rather than by inviting that person to the team. Company roles do not belong to a team, so an invitation has to be created to carry one. It is a real invitation and can carry a team role of its own, but cancelling the company role withdraws it entirely, where cancelling a role added onto an ordinary invitation leaves that invitation standing. A team role never produces one of these, because a team role is part of a membership and cannot exist without it. |
| `on_no_team` | boolean | True for a company-role holder who is on no team at all. It does not move with is_active, so a person who is on a deactivated team is a member of it, never a role-only row. Only an administrative reading includes such rows at all. |

### A person is an email address

`key` is the email address, and it is the field to build on. An invitation that
nobody has accepted has no user behind it yet, so `user_id` is `null` and stays
null until someone signs up. The address is what every membership record is
keyed by, what the invitation was sent to, and what the person keeps as they
join more teams.

That also means an address is a person here. Somebody invited under one address
who later signs up under a different one is two rows, because nothing in the
data ties them together.

## Deactivated teams

A team can be deactivated without being deleted, and its memberships stay. By
default this endpoint returns people through **both** activated and deactivated
teams, the same way [List teams](/docs/api/teams/list) returns both when you
omit `is_active`. Pass `is_active=true` for the company as it stands today, or
`is_active=false` to read only what has been archived.

The filter decides which memberships a person is assembled from, so it moves
more than the row count:

```bash
# Someone on two activated teams and one deactivated one
curl "…/members?company_id=Cm4pQ7…&email=sam@example.com"               # membership_count: 3
curl "…/members?company_id=Cm4pQ7…&email=sam@example.com&is_active=true" # membership_count: 2
```

`status` follows it too: a person whose only accepted membership is on a
deactivated team reads as `is_pending: true` under `is_active=true`, because
the invitation they accepted is not in that reading.

Each entry in `membership_teams` carries its own `is_active`, so you can show both kinds
at once and mark the archived ones rather than filtering server side.

Two things deliberately do **not** move with it. Scope is one: which teams you
may see people on is decided by your permissions and your memberships, and a
team you were on is still yours to ask about after it is deactivated. The other
is `on_no_team`, which means on no team at all. If it followed the filter, then
`is_active=false` would answer with every company-role holder who is on no
deactivated team, which is most of them.

One consequence to know: a company-role holder whose teams are *all*
deactivated is in neither half of `is_active=true`. They have no activated
membership, so they are not a member there, and they are on a team, so they are
not a role-only row either. The default reading and `is_active=false` both list
them, as the member they are.

## Paging

Cursor paginated, the same way as every other listing. Read `has_more`, and when
it is true send `next_cursor` back as `cursor`:

```bash
# The first page
curl "https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…&limit=50" \
  -H "Authorization: Bearer $RUNDIFFUSION_TOKEN"

# The next one
curl "https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…&limit=50&cursor=2D8NEx…" \
  -H "Authorization: Bearer $RUNDIFFUSION_TOKEN"
```

A cursor belongs to the listing it was issued for: the company, the sort, the
order and every filter. Change any of them partway through a walk and the cursor
returns `400 INVALID_CURSOR` rather than quietly resuming somewhere else in a
different result set, so keep them constant for the length of a walk and start
again without a cursor when you change one.

Two things deliberately do not invalidate a cursor. `limit` may change between
pages, because a cursor is a position and not an offset. And your scope is
re-applied to every request, so a cursor is never a way to reach people you
could not otherwise see.

Every sort breaks ties on the email address, which is what makes the order
total, so a person cannot appear on two pages or fall between them.

## Finding one person

`email` matches exactly and is the right way to resolve a known address:

```bash
curl "https://api2.rundiffusion.com/api/v2/members?company_id=Cm4pQ7…&email=sam@example.com" \
  -H "Authorization: Bearer $RUNDIFFUSION_TOKEN"
```

`q` is a substring search for a person-facing search box, so `q=sam` also finds
`sammy@example.com` and `samira@example.org`. Both can be combined with the
other filters.

> **This response is a list of people's email addresses**
>
> Every row carries an address, and for an administrator that is the whole
> company's. Treat a page of this endpoint the way you would treat any export of
> personal data: keep it out of shared logs and analytics, and do not cache it
> anywhere your own access controls do not reach.

## Errors

**Status codes**

| Name | Type | Description |
| --- | --- | --- |
| `400` | INVALID_REQUEST | A query parameter failed validation, such as a limit above 100 or a sort this endpoint does not offer. details names the field. |
| `400` | INVALID_CURSOR | The cursor no longer matches this listing, which happens if you change the sort, the order or a filter partway through a walk. Restart the walk without a cursor. |
| `401` | UNAUTHENTICATED | Missing, malformed, or expired token. Refresh it and retry. |
| `403` | PERMISSION_DENIED | The caller holds nothing in the company named, meaning no membership on any of its teams and neither MANAGE_TEAMS nor MANAGE_COMPANY. A Personal API Access Token is refused here too. |
| `429` | RATE_LIMITED | Too many requests. See [Rate limits](/docs/api/rate-limits#members). |

A caller who holds nothing in the company named gets `403`, with a message that
says what would grant access. It reads the same whether or not the company
exists, so the response does not answer that question for someone outside it.
