---
id: project-roles
slug: /apis/project-roles
title: 'Project Roles (api/ProjectRoles)'
description: 'Read which members hold which roles on which projects. A read-only, self-describing endpoint that publishes its own RAML specification.'
sidebar_label: 'Project Roles (api/ProjectRoles)'
sidebar_position: 12
tags: ['api', 'project-roles', 'members', 'raml']
---

Reports which members hold which roles on which projects, from either direction — grouped by
project, or grouped by member.

It is **read-only by design.** There is no create, update or delete here. Role assignments are
changed through the [Bulk API](/apis/asset-bulk), which is the only API endpoint intended for
that, and are governed by Agility's rights and privileges.

## It Describes Itself

Unusually for Agility, this endpoint publishes a **machine-readable specification of itself**:

```http
GET ~/api/ProjectRoles
```

returns a complete **RAML 1.0** document as `text/plain` — type definitions, both resources,
their query parameters, response schemas and worked examples. Roughly 14 KB.

**No authentication is required for the specification**, which makes it usable for client
generation and exploration without a token. It returns
`200 text/plain` unauthenticated, while the two data resources below return `401`.

:::note This is the only self-describing Agility endpoint

Nothing else in the API publishes a machine-readable contract of itself. The API surface as a
whole has no OpenAPI or RAML description — the only other machine-readable artifacts this site
publishes are the [ANTLR query grammars](/reference/query-grammar/ebnf).

Do not generalise the pattern: `api/ProjectRoles` describes `api/ProjectRoles` and nothing else.

:::

## Resources

Both are `GET` and both require authentication.

### Grouped by Project

```http
GET ~/api/ProjectRoles/ByProject?where=Name='New Project'&membersWhere=Nickname='JDoe'
```

| Parameter | Description |
| --- | --- |
| `where` | Filters the **projects** returned |
| `membersWhere` | Filters the **members** within each project |
| `page` | Pages the projects, and the members within each project — see [Paging](#paging) |
| `sort` | Orders the **projects** — see [Sort](#sort) |

Returns `{ total, assets: ProjectMemberRoles[] }`, where each entry pairs a project with the
members holding roles on it.

### Grouped by Member

```http
GET ~/api/ProjectRoles/ByMember?where=Nickname='JDoe'&projectsWhere=Name='New Project'
```

| Parameter | Description |
| --- | --- |
| `where` | Filters the **members** returned |
| `projectsWhere` | Filters the **projects** within each member |
| `page` | Pages the members, and the projects within each member — see [Paging](#paging) |
| `sort` | Orders the **members** — see [Sort](#sort) |

Returns `{ total, assets: MemberProjectRoles[] }` — the same relationships from the other side.

Note the symmetry in the parameter naming: `where` always filters the thing you grouped by, and
the other parameter filters what is nested inside it.

Give at least one of the two filters. With neither, a resource returns no data: it answers
`200 text/plain` with its own section of the [specification](#it-describes-itself).

## Paging

`page` takes two pages in one value, separated by a semicolon:

```text
page=outerSize,outerStart;innerSize,innerStart
```

- The **outer** page applies to the list you grouped by: projects on `ByProject`, members on
  `ByMember`.
- The **inner** page applies to the nested list inside **each** group, separately for each
  group. `page=10,0;5,0` returns up to 10 projects, each listing its first 5 members. The inner
  list is in ID order.
- A size without a start means start `0`, so `page=10` is `page=10,0`.

```http
GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&page=10,0;5,0
GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&page=10,10;5,0
```

:::warning Always send an outer page

Without `page`, the outer list is **not paged**: every matching project (or member) comes
back in one response. The inner list is still capped at **50** per group, so a project with
more than 50 members is silently cut short unless you page its members yourself.

:::

A `page` value that can't be parsed is not rejected. The segment falls back to `50,0`, so a typo
gives you a different page, not a `400`.

### `total` Counts This Response, Not the Matches

`total` is the number of groups in this response. It is not the number of projects or members
that match `where`, and it can't tell you whether more pages remain:

- `ByProject` leaves out any project with no matching member roles.
- `ByMember` leaves out any member with no matching project roles when `projectsWhere` is given.

So a page can hold fewer groups than its size while more remain. To know how many pages to
request, count the matches through [rest-1](/apis/rest-1) with the same `where` and an explicit
`needTotal=true`, which returns the total even for a one-row page:

```http
GET ~/rest-1.v1/Data/Scope?where=AssetState='64'&page=1,0&needTotal=true
```

Use `Data/Member` for `ByMember`. Send `needTotal=true` every time; its default varies by instance
(see [paging totals](/reference/query-grammar/sort-and-paging)).

## Sort

`sort` orders the list you grouped by: projects on `ByProject`, members on `ByMember`. It takes
one or more attribute names of that type, separated by commas, each optionally prefixed with `-`
for descending:

```http
GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&sort=-Name,+ID&page=10,0
```

- The default is `+ID`.
- The nested list is not affected: members within a project, and projects within a member, are
  always in ID order.
- An attribute name the server doesn't recognize is ignored without an error, and you get the
  default order. If your order looks like ID order, check the attribute name.

## Response Shape

```json
{
  "total": 2,
  "assets": [
    {
      "project": { "oidToken": "Scope:1003", "name": "New Project" },
      "memberRoles": [
        {
          "isOwner": true,
          "member": {
            "oidToken": "Member:20",
            "name": "Administrator",
            "email": "admin@v1.com",
            "defaultRole": { "oidToken": "Role:1", "name": "System Admin" }
          },
          "role": { "oidToken": "Role:1", "name": "System Admin" }
        }
      ]
    }
  ]
}
```

:::tip `defaultRole` and `role` are different things, and the difference matters

Both appear in every response and they answer different questions.

- **`member.defaultRole`** is what the UI presents as **Admin Privileges**. It determines the
  member's **global privileges**, and it is the role they will act as by default when they are
  given access to a new Scope.
- **`role`** is the role that member actually holds **on this specific project**.

They frequently differ, and neither implies the other. A member whose `defaultRole` is
`Team Member` can hold `Project Lead` on one project; a member whose `defaultRole` is
`System Admin` carries those global privileges regardless of what any individual project says.

If you are answering "what can this person do here?", read `role`. If you are answering "what
can this person do generally, and what will they get on their next project?", read
`defaultRole`.

:::

`isOwner` flags the project owner, distinct from any role assignment.

## Related

- [Bulk API](/apis/asset-bulk) — the supported way to change role assignments
- [Meta API](/apis/meta-v1) — `Member`, `Role` and `Scope` schema
- [Query grammar](/reference/query-grammar) — the filter syntax `where` accepts
