---
title: Provision a team | LlamaCloud Admin API
description: Create a project, mint a key for it, give an existing member access, and add someone who is not yet a member — end to end in Python and TypeScript.
---

Three goals, in the order you would actually run them:

1. Start from an organization and a **user-scoped** API key.
2. Create a project, mint a key for it, and give users access.
3. Add users to the organization as an admin.

Every code block is given in both languages. Pick one at the first tab and the rest of the page follows it.

## Before you start

### Get a user-scoped key

The API key page carries two tables — *Project API Keys* and *User API Keys*. You need one from **User API Keys**.

Check that the User API Keys table is there at all

The *User API Keys* table is gated on your organization’s plan. Open your organization’s key page and check: if it shows only a *Project API Keys* table, user-scoped keys are not available on your plan and no recipe on this page can run — contact support\@runllama.ai about the plan your organization is on. Step 3 reads `usage.plan.name`, which is the same thing the gate looks at. A project-scoped key is not a substitute; it is pinned to one project and cannot reach the organization surface at all.

If you do see the table, this is the procedure:

1. Open the key page for your region:

   - North America — `https://cloud.llamaindex.ai/organization/<organization_id>/settings/api-keys`
   - Europe — `https://cloud.eu.llamaindex.ai/organization/<organization_id>/settings/api-keys`

   If you do not have the organization id handy, `https://cloud.llamaindex.ai/?target=api-key` resolves it for you (Europe: the same path on `cloud.eu.llamaindex.ai`). In the sidebar it is the **API Key** entry.

2. Create a key under **User API Keys** — not Project API Keys.

3. Export it as `LLAMA_CLOUD_API_KEY`. Both clients read that exact variable and raise at construction if it is missing.

### Pick your region

| Region        | API base URL                                           |
| ------------- | ------------------------------------------------------ |
| North America | `https://api.cloud.llamaindex.ai` (the client default) |
| Europe        | `https://api.cloud.eu.llamaindex.ai`                   |

Both clients read `LLAMA_CLOUD_ADMIN_BASE_URL` for the override, or you can pass it to the constructor.

A wrong-region key reads like a bad key

A key issued in one region is rejected by the other with **401** and the detail `Invalid API Key. Please check your region ...`. It looks like an expired or mistyped key. Check the base URL before you regenerate anything.

### Install

- [Python](#tab-panel-0)
- [TypeScript](#tab-panel-1)

Terminal window

```
pip install git+https://github.com/run-llama/llamacloud-admin-python.git
```

Terminal window

```
npm install git+https://github.com/run-llama/llamacloud-admin-typescript.git
```

### Confirm your key is user-scoped

This is the single most common reason the recipes below fail in a confusing way, and the check is read-only — it writes nothing either way. Call `projects.list` with **no** `organization_id`:

- A **user-scoped** key gets **400** `organization_id is required when not using a project-scoped API key`.
- A **project-scoped** key gets **200** and exactly the one project it is pinned to.

* [Python](#tab-panel-2)
* [TypeScript](#tab-panel-3)

```
from llama_cloud_admin import BadRequestError, LlamaCloudAdmin


client = LlamaCloudAdmin()


try:
    page = client.projects.list()
except BadRequestError:
    print("User-scoped key. Continue.")
else:
    pinned = page.items[0].name if page.items else "(none)"
    print(f"Project-scoped key, pinned to {pinned!r}. Stop — get a user key.")
```

```
import LlamaCloudAdmin, { BadRequestError } from "@llamaindex/llama-cloud-admin";


const client = new LlamaCloudAdmin();


try {
  const page = await client.projects.list();
  const pinned = page.items[0]?.name ?? "(none)";
  console.log(`Project-scoped key, pinned to ${pinned}. Stop — get a user key.`);
} catch (err) {
  if (!(err instanceof BadRequestError)) throw err;
  console.log("User-scoped key. Continue.");
}
```

Two corroborating signals, if you skipped the probe: `projects.create` returns **403** `API key is restricted to a single project`, and any project route naming a different project returns **401** `Project ID specified in the URL does not match the project ID associated with the API key`.

## Set up the client

Everything below assumes this block has run.

- [Python](#tab-panel-4)
- [TypeScript](#tab-panel-5)

```
import os


from llama_cloud_admin import APIStatusError, LlamaCloudAdmin


# api_key comes from LLAMA_CLOUD_API_KEY; base_url from LLAMA_CLOUD_ADMIN_BASE_URL,
# defaulting to https://api.cloud.llamaindex.ai. For Europe, either export that
# variable or pass base_url="https://api.cloud.eu.llamaindex.ai" here.
client = LlamaCloudAdmin()


MY_EMAIL = os.environ["LLAMA_CLOUD_ADMIN_EMAIL"]  # your own login address
PROJECT_NAME = os.environ.get("DEMO_PROJECT_NAME", "admin-sdk-demo")
TEAMMATE_EMAIL = "teammate@example.com"  # already a member of the organization
NEWHIRE_EMAIL = "newhire@example.com"  # not a member yet




def describe(err: APIStatusError) -> str:
    """Status, the API's own message, and the id support will ask for."""
    detail = err.body.get("detail") if isinstance(err.body, dict) else None
    request_id = err.response.headers.get("x-request-id")
    return f"{err.status_code} {detail or err.message} (x-request-id: {request_id})"
```

```
import LlamaCloudAdmin, { APIError } from "@llamaindex/llama-cloud-admin";


// apiKey comes from LLAMA_CLOUD_API_KEY; baseURL from LLAMA_CLOUD_ADMIN_BASE_URL,
// defaulting to https://api.cloud.llamaindex.ai. For Europe, either export that
// variable or pass baseURL: "https://api.cloud.eu.llamaindex.ai" here.
const client = new LlamaCloudAdmin();


const MY_EMAIL = process.env.LLAMA_CLOUD_ADMIN_EMAIL!; // your own login address
const PROJECT_NAME = process.env.DEMO_PROJECT_NAME ?? "admin-sdk-demo";
const TEAMMATE_EMAIL = "teammate@example.com"; // already a member of the organization
const NEWHIRE_EMAIL = "newhire@example.com"; // not a member yet


/** Status, the API's own message, and the id support will ask for. */
function describe(err: unknown): string {
  if (!(err instanceof APIError)) return String(err);
  const detail = (err.error as { detail?: string } | undefined)?.detail;
  const requestId = err.headers?.get("x-request-id");
  return `${err.status ?? "?"} ${detail ?? err.message} (x-request-id: ${requestId})`;
}
```

Tip

The two clients expose error bodies differently — Python has no `.error`, TypeScript has no `.body`. Use each one’s own idiom; see [Troubleshooting](/cookbook/troubleshooting#reading-an-error/index.md).

## 1. Find your organization

- [Python](#tab-panel-6)
- [TypeScript](#tab-panel-7)

```
ORG_ID = os.environ.get("LLAMA_CLOUD_ORG_ID")


if not ORG_ID:
    # `name` here is a case-insensitive PARTIAL match, applied server-side.
    page = client.organizations.list(name="acme")
    if not page.items:
        raise SystemExit("No match. You are not a member of an organization by that name.")
    ORG_ID = page.items[0].id


print(ORG_ID)
```

```
let ORG_ID = process.env.LLAMA_CLOUD_ORG_ID;


if (!ORG_ID) {
  // `name` here is a case-insensitive PARTIAL match, applied server-side.
  const page = await client.organizations.list({ name: "acme" });
  if (page.items.length === 0) {
    throw new Error("No match. You are not a member of an organization by that name.");
  }
  ORG_ID = page.items[0]!.id;
}


console.log(ORG_ID);
```

`organizations.list` is membership-scoped: **an organization you have no grant in is simply absent from the page.** There is no 403 and no 404 to catch — an empty result and a permissions problem look identical, which is why the verification step at the end resolves your own membership explicitly.

Both list methods are cursor-paginated, and the code above reads only the first page. To walk every page, iterate the call itself — `for org in client.organizations.list(...)` in Python, `for await (const org of client.organizations.list(...))` in TypeScript — and the client follows the cursor for you.

The two name filters do not match on the same rule

`organizations.list(name=...)` is a case-insensitive **partial** match. `projects.list(name=...)` is an **exact** match. Reusing one query shape for both is a silent no-result.

## 2. Look up role ids

Role ids are generated per deployment. An id copied from another environment — or hardcoded from a previous run against a different deployment — resolves to nothing. Resolve them by name at runtime, once, and cache the map.

- [Python](#tab-panel-8)
- [TypeScript](#tab-panel-9)

```
roles = client.organizations.roles.list(ORG_ID)
ROLE_IDS = {role.name: role.id for role in roles}


print(sorted(ROLE_IDS))  # e.g. ['Admin', 'AgentViewer', 'Viewer', 'ViewerV2']


# Resolve the one role the rest of this page grants, and stop if this
# organization does not offer it rather than sending an unresolvable id.
VIEWER_V2 = ROLE_IDS.get("ViewerV2")
if VIEWER_V2 is None:
    raise SystemExit("No ViewerV2 role here. Available: " + ", ".join(sorted(ROLE_IDS)))
```

```
const roles = await client.organizations.roles.list(ORG_ID);
const ROLE_IDS = new Map(roles.map((role) => [role.name, role.id]));


console.log([...ROLE_IDS.keys()]); // e.g. ['Admin', 'Viewer', 'ViewerV2', 'AgentViewer']


// Resolve the one role the rest of this page grants, and stop if this
// organization does not offer it rather than sending an unresolvable id.
const VIEWER_V2 = ROLE_IDS.get("ViewerV2");
if (!VIEWER_V2) {
  throw new Error(`No ViewerV2 role here. Available: ${[...ROLE_IDS.keys()].join(", ")}`);
}
```

Match on the name and treat the id as an opaque per-deployment value. Which names come back can vary — the organization id filters which roles are visible to you — so read the list you actually get rather than assuming a fixed set. Usually it is `Admin`, `Viewer`, `ViewerV2`, and `AgentViewer`. If a deployment ever offers two roles under one name, a name-keyed map keeps an arbitrary one of them — check `len(roles)` against the number of distinct names if that matters to you.

At organization scope, only Admin and ViewerV2 survive as themselves

Assigning any other role organization-wide collapses it to a plain viewer grant. Granting `AgentViewer` across the organization does not produce an `AgentViewer` organization grant.

This call needs only a valid key — the organization id filters which roles are visible, it does not authorize you. Succeeding here is **not** evidence that you are an admin of that organization.

## 3. Goal A — create a project

### Check the plan’s headroom first

Read-only, needs only read access to the organization, and it tells you the two numbers behind two of the four different 429s on this API.

- [Python](#tab-panel-10)
- [TypeScript](#tab-panel-11)

```
usage = client.organizations.get_usage(ORG_ID)
limits = usage.plan.limits


print(usage.plan.name)
print("projects:", limits.max_projects)  # None = unlimited
print("seats:", usage.usage.total_users, "of", limits.max_users)
```

```
const usage = await client.organizations.getUsage(ORG_ID);
const limits = usage.plan.limits;


console.log(usage.plan.name);
console.log("projects:", limits.max_projects); // null = unlimited
console.log("seats:", usage.usage.total_users, "of", limits.max_users);
```

`max_projects` is the cap that turns `projects.create` into a 429, and `max_users` is the cap that turns `users.add` into one. Pending invites count toward `total_users`. On a BYOC or self-hosted deployment neither cap applies.

### Create it

- [Python](#tab-panel-12)
- [TypeScript](#tab-panel-13)

```
from llama_cloud_admin import ConflictError, RateLimitError




def find_project(name: str):
    # `name` on projects.list is an EXACT match.
    page = client.projects.list(organization_id=ORG_ID, name=name)
    return page.items[0] if page.items else None




try:
    # projects.create is fully keyword-only — there is no positional org id.
    project = client.projects.create(organization_id=ORG_ID, name=PROJECT_NAME)
except ConflictError:
    project = find_project(PROJECT_NAME)  # 409: the name is taken in this organization
    if project is None:
        raise
except RateLimitError as err:
    raise SystemExit(f"Plan project cap reached — {describe(err)}")


# is_default is optional AND nullable here; created_at is a datetime, not a string.
print(project.id, project.name, "default" if bool(project.is_default) else "regular")
print(project.created_at.isoformat() if project.created_at else "unknown")
```

```
import { ConflictError, RateLimitError } from "@llamaindex/llama-cloud-admin";


async function findProject(name: string) {
  // `name` on projects.list is an EXACT match.
  const page = await client.projects.list({ organization_id: ORG_ID, name });
  return page.items[0];
}


let project: LlamaCloudAdmin.Project;
try {
  project = await client.projects.create({ organization_id: ORG_ID, name: PROJECT_NAME });
} catch (err) {
  if (err instanceof ConflictError) {
    const existing = await findProject(PROJECT_NAME); // 409: the name is taken here
    if (!existing) throw err;
    project = existing;
  } else if (err instanceof RateLimitError) {
    throw new Error(`Plan project cap reached — ${describe(err)}`);
  } else {
    throw err;
  }
}


// is_default is optional but never null here; created_at is a string, not a Date.
console.log(project.id, project.name, project.is_default ?? false);
console.log(project.created_at ?? "unknown");
```

A 409 can name a project you cannot find

Project names are unique within an organization, and a **deleted** project still holds its name. So the fall-back lookup above can come back empty even though the create was refused as a duplicate — that is the case where re-raising is correct. Pick a different name.

Creating a project needs write access to the organization. Lacking it returns **404** `Organization <id> not found`, not 403.

### Mint a key for it

A project-scoped key is what you hand to the application or teammate that will work in this project. It reaches that project and no other — unlike the user-scoped key driving this script, which reaches everything you can.

Inside that project it is not a reduced key. Like every key it authenticates as the account that created it, so the holder acts with your rights there, up to deleting the project. Scope the account, not the key: if that is more than the holder should have, create the key from an account whose access matches.

The secret is shown once, and the field name says otherwise

`redacted_api_key` is redacted on every read, but on the create response it carries the live key. Nothing returns it again — there is no retrieve method on this resource. Capture it, and the key’s `id`, before the value goes out of scope; recovering from a lost secret means deleting that key and creating another.

- [Python](#tab-panel-14)
- [TypeScript](#tab-panel-15)

```
from datetime import datetime, timedelta, timezone


key = client.api_keys.create(
    project_id=project.id,
    name=f"{PROJECT_NAME} deploy key",
    # Optional, and worth setting. Omit it and the key never expires.
    expires_at=datetime.now(timezone.utc) + timedelta(days=90),
)


# Named redacted_api_key, but on the create response it is the live secret.
print("store this now, it is not shown again:", key.redacted_api_key)
print("key id, for revoking it later:", key.id)
```

```
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();


const key = await client.apiKeys.create({
  project_id: project.id,
  name: `${PROJECT_NAME} deploy key`,
  // Optional, and worth setting. Omit it and the key never expires.
  expires_at: expiresAt,
});


// Named redacted_api_key, but on the create response it is the live secret.
console.log("store this now, it is not shown again:", key.redacted_api_key);
console.log("key id, for revoking it later:", key.id);
```

`api_keys.list(project_id=...)` shows every key on that project — its members share them — with the secrets masked. Pass the project: called bare it lists your own keys instead, which is a different question and easy to mistake for an empty project. `api_keys.delete` revokes one, taking access away from everyone using it.

## 4. Goal B — give an existing member access to that project

First resolve the person to a user id. A member who has been invited but has not signed up yet has no `user_id`, so this step doubles as the check for whether they are really a member.

- [Python](#tab-panel-16)
- [TypeScript](#tab-panel-17)

```
members = client.organizations.users.list_members(ORG_ID)
target = next(
    (m for m in members if (m.email or "").lower() == TEAMMATE_EMAIL.lower()), None
)
if target is None or not target.user_id:
    raise SystemExit(f"{TEAMMATE_EMAIL} is not an active member yet — see Goal C.")


client.organizations.users.add_to_project(
    target.user_id,  # positional: the USER id, not the organization id
    organization_id=ORG_ID,  # keyword
    project_id=project.id,  # ALWAYS pass this
)


granted = client.organizations.users.list_projects(target.user_id, organization_id=ORG_ID)
print([p.name for p in granted])
```

```
const members = await client.organizations.users.listMembers(ORG_ID);
const target = members.find(
  (m) => m.email?.toLowerCase() === TEAMMATE_EMAIL.toLowerCase(),
);
if (!target?.user_id) {
  throw new Error(`${TEAMMATE_EMAIL} is not an active member yet — see Goal C.`);
}


await client.organizations.users.addToProject(target.user_id, {
  organization_id: ORG_ID,
  project_id: project.id, // ALWAYS pass this
});


const granted = await client.organizations.users.listProjects(target.user_id, {
  organization_id: ORG_ID,
});
console.log(granted.map((p) => p.name));
```

Omitting project\_id still returns 200 — on the wrong project

`project_id` is optional in the signature and it should not be. Leave it out and the call grants access to the organization’s **default** project instead — creating a default project if the organization has none. The response is a success and nothing in it names the project that was touched. Always pass `project_id` explicitly.

This call needs write access to the organization (**404** `Organization <id> not found` if you lack it) and read access to the named project (**404** `Project <id> is not found`). The organization is checked first, so a 404 naming the organization means the project id was never reached.

## 5. Goal C — add someone who is not yet a member

`users.add` takes the organization id positionally and the members as a `body` array. `project_ids` is a **required** key in each entry — omitting it is a 422, not a default.

- [Python](#tab-panel-18)
- [TypeScript](#tab-panel-19)

```
added = client.organizations.users.add(
    ORG_ID,  # positional
    body=[  # one member per call — see the warning below
        {
            "email": NEWHIRE_EMAIL,
            "project_ids": None,  # required key; None = organization-wide
            "role_id": VIEWER_V2,
        }
    ],
)


print([(m.email, m.pending) for m in added])
```

```
const added = await client.organizations.users.add(ORG_ID, {
  body: [
    // one member per call — see the warning below
    {
      email: NEWHIRE_EMAIL,
      project_ids: null, // required key; null = organization-wide
      role_id: VIEWER_V2,
    },
  ],
});


console.log(added.map((m) => [m.email, m.pending]));
```

### The two shapes, and which one you want

The value of `project_ids` picks between two genuinely different outcomes. Both make the person a member — they appear in `list_members` as pending — and both send the invitation email. What differs is what they can act on.

| `project_ids`                                                     | What they get                                                | Appears in their `invites.list_mine()`                                |
| ----------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------- |
| `null`                                                            | Membership in the organization, with the role you named      | **Yes.** A real invite, with an id they can `accept()` or `decline()` |
| A list of project ids, with `Admin`, `ViewerV2`, or `AgentViewer` | Access to exactly those projects. No organization-wide grant | **No.** Nothing to accept                                             |

The project-scoped shape grants access to the named projects only, and the invite list surfaces organization-scope invites — so the person receives the email, shows as pending to you, and has an empty invite list. Their access materializes when they sign up.

**Use this one when:**

- **You want them in the organization** — `project_ids: null`. This is the only shape that produces an invite they can accept, so it is the right default.
- **You want an outside collaborator confined to named projects** — pass the project ids. Accept that there is nothing for them to accept, and tell them to sign up rather than to look for an invite.
- **You want both** — add them organization-wide with `project_ids: null` first, then grant the project with `add_to_project` as in Goal B. Two calls, and the invite still works.

One member per users.add call

In a multi-member batch, every entry’s `project_ids` is applied to every member in the batch — everyone ends up with everyone else’s project grants. Loop over your members and send one call each.

Re-adding an existing member is not a no-op

It re-sends the invitation email, and it clears that person’s default-organization flag. Check `list_members` before you add.

Two of the four 429s live on this call: an invite rate limit of 10 requests per 60 seconds per organization, and the plan’s seat cap. They are told apart by `detail`, not by status — see [Troubleshooting](/cookbook/troubleshooting#429-means-one-of-four-things/index.md). One more thing the response cannot tell you: an email-only request always comes back reading as pending, whether or not that address already had an account.

## 6. The invitee’s side

Invites are read and acted on by the **invitee**, with their own key. There is no way to accept on someone else’s behalf — the API returns 403 `Invite <id> is not addressed to user <id>`.

- [Python](#tab-panel-20)
- [TypeScript](#tab-panel-21)

```
invitee = LlamaCloudAdmin(api_key=os.environ["DEMO_INVITEE_API_KEY"])


page = invitee.invites.list_mine()
for invite in page.items:
    print(invite.id, invite.organization_name, invite.role)


if page.items:
    invitee.invites.accept(page.items[0].id)  # or .decline(page.items[0].id)
```

```
const inviteeKey = process.env.DEMO_INVITEE_API_KEY;
if (!inviteeKey) {
  // Without this, the client falls back to LLAMA_CLOUD_API_KEY — your own admin
  // key — and would list and accept your invites instead of theirs.
  throw new Error("Set DEMO_INVITEE_API_KEY to the invitee's own user-scoped key.");
}
const invitee = new LlamaCloudAdmin({ apiKey: inviteeKey });


const page = await invitee.invites.listMine();
for (const invite of page.items) {
  console.log(invite.id, invite.organization_name, invite.role);
}


const first = page.items[0];
if (first) await invitee.invites.accept(first.id); // or .decline(first.id)
```

Accepting converts the pending grants for that address into grants on their user account, across the organization and the projects under it. Declining deletes them, so a later re-invite issues fresh ones. An invite that has already been redeemed returns 404 `Invite <id> not found or already redeemed`.

Signing up also redeems whatever is pending for that address, which is how the project-scoped shape from Goal C delivers access without an invite to accept.

## 7. Changing a role later

- [Python](#tab-panel-22)
- [TypeScript](#tab-panel-23)

```
from llama_cloud_admin import BadRequestError


try:
    assigned = client.organizations.users.assign_role(
        ORG_ID,  # path_organization_id, positional
        body_organization_id=ORG_ID,  # the same id again, in the body
        role_id=VIEWER_V2,
        user_id=target.user_id,
    )
    print(assigned.role.name, assigned.project_ids)
except BadRequestError as err:
    print(describe(err))  # e.g. targeting yourself
```

```
import { BadRequestError } from "@llamaindex/llama-cloud-admin";


try {
  const assigned = await client.organizations.users.assignRole(ORG_ID, {
    organization_id: ORG_ID, // the same id again, in the body
    role_id: VIEWER_V2,
    user_id: target.user_id,
  });
  console.log(assigned.role.name, assigned.project_ids);
} catch (err) {
  if (!(err instanceof BadRequestError)) throw err;
  console.log(describe(err)); // e.g. targeting yourself
}
```

**The organization id is sent twice** — once in the path and once in the body. The Python client names them `path_organization_id` and `body_organization_id` so they cannot silently collide; pass the same value to both. Leaving it out of the body is a **422**, not an inherited path value.

Grants add. They never replace.

Assigning a second role does not remove the first — the member ends up holding both, and the more permissive one wins. There is no downgrade through this call and no revoke. The only calls that take access away are `users.remove_from_project`, which drops one project grant, and `users.delete`, which removes the person from the organization. Promote deliberately, and default to the least privilege that does the job rather than to `Admin`.

You cannot target yourself: **400** `User cannot assign role to themselves`. There is no path through this API to change your own role.

The denial here is **404**, not 403 — see the [permission table](/cookbook/troubleshooting#permission-table/index.md) for the one rare case that does return 403.

## 8. Verify

- [Python](#tab-panel-24)
- [TypeScript](#tab-panel-25)

```
members = client.organizations.users.list_members(ORG_ID)
me = next((m for m in members if (m.email or "").lower() == MY_EMAIL.lower()), None)


# A role row WITH project_ids is a project grant. Only a row with no project_ids
# is organization-wide — and that is what every write on this page requires.
i_am_org_admin = me is not None and any(
    r.role.name == "Admin" and not r.project_ids for r in me.roles
)
print("organization admin:", i_am_org_admin)


watch = {TEAMMATE_EMAIL.lower(), NEWHIRE_EMAIL.lower()}
for m in members:
    if (m.email or "").lower() in watch:
        print(
            m.email,
            "pending" if m.pending else "active",
            [(r.role.name, r.project_ids) for r in m.roles],
        )
```

```
const roster = await client.organizations.users.listMembers(ORG_ID);
const me = roster.find((m) => m.email?.toLowerCase() === MY_EMAIL.toLowerCase());


// A role row WITH project_ids is a project grant. Only a row with no project_ids
// is organization-wide — and that is what every write on this page requires.
const iAmOrgAdmin =
  !!me && (me.roles ?? []).some((r) => r.role.name === "Admin" && !r.project_ids?.length);
console.log("organization admin:", iAmOrgAdmin);


const watch = new Set([TEAMMATE_EMAIL.toLowerCase(), NEWHIRE_EMAIL.toLowerCase()]);
for (const m of roster) {
  if (m.email && watch.has(m.email.toLowerCase())) {
    console.log(
      m.email,
      m.pending ? "pending" : "active",
      m.roles.map((r) => [r.role.name, r.project_ids]),
    );
  }
}
```

A member named Admin is not necessarily an organization admin

`list_members` folds project grants into the same `roles` array and labels a project-level admin grant `Admin` too. Checking the name alone reports someone as an organization admin who will get a 404 on every call on this page. The discriminator is `project_ids`: only a row with none is organization-wide.

## Everything on this page, in one table

| Step                   | Python                                 | TypeScript                           | HTTP                                                                   |
| ---------------------- | -------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
| Probe the key’s scope  | `projects.list()`                      | `projects.list()`                    | `GET /api/v2/projects`                                                 |
| Find your organization | `organizations.list()`                 | `organizations.list()`               | `GET /api/v2/organizations`                                            |
| Resolve role ids       | `organizations.roles.list()`           | `organizations.roles.list()`         | `GET /api/v1/organizations/{organization_id}/roles`                    |
| Read the plan’s caps   | `organizations.get_usage()`            | `organizations.getUsage()`           | `GET /api/v1/organizations/{organization_id}/usage`                    |
| Create a project       | `projects.create()`                    | `projects.create()`                  | `POST /api/v2/projects`                                                |
| Find a project by name | `projects.list()`                      | `projects.list()`                    | `GET /api/v2/projects`                                                 |
| List members           | `organizations.users.list_members()`   | `organizations.users.listMembers()`  | `GET /api/v1/organizations/{organization_id}/users`                    |
| Grant project access   | `organizations.users.add_to_project()` | `organizations.users.addToProject()` | `PUT /api/v1/organizations/{organization_id}/users/{user_id}/projects` |
| Confirm project access | `organizations.users.list_projects()`  | `organizations.users.listProjects()` | `GET /api/v1/organizations/{organization_id}/users/{user_id}/projects` |
| Add a non-member       | `organizations.users.add()`            | `organizations.users.add()`          | `PUT /api/v1/organizations/{organization_id}/users`                    |
| See your own invites   | `invites.list_mine()`                  | `invites.listMine()`                 | `GET /api/v2/invites`                                                  |
| Accept an invite       | `invites.accept()`                     | `invites.accept()`                   | `POST /api/v2/invites/{invite_id}/accept`                              |
| Change a role          | `organizations.users.assign_role()`    | `organizations.users.assignRole()`   | `PUT /api/v1/organizations/{organization_id}/users/roles`              |
| Mint a project key     | `api_keys.create()`                    | `apiKeys.create()`                   | `POST /api/v1/beta/api-keys`                                           |
| List a project’s keys  | `api_keys.list()`                      | `apiKeys.list()`                     | `GET /api/v1/beta/api-keys`                                            |
| Revoke a key           | `api_keys.delete()`                    | `apiKeys.delete()`                   | `DELETE /api/v1/beta/api-keys/{api_key_id}`                            |

When something on this page returns a status you did not expect, the decoder is [Troubleshooting](/cookbook/troubleshooting/index.md).
