---
title: "Invite Member"
method: POST
path: "/roles/invite"
tags: ["Roles"]
---

# Invite Member

`POST /roles/invite`

## Request

- Base URL: `https://be.graph8.com/api/v1`
- URL: `https://be.graph8.com/api/v1/roles/invite`
- Auth: HTTP bearer

Invite one person into the organisation, at a role below administrator.

`status` in the response is the app's own: `invited` when an invitation
email went out to someone with no graph8 account, `added` when the person
already had one and now holds the role immediately. Re-inviting the same
address is safe - the pending invitation is UPDATED to the new role rather
than duplicated - so a reconciliation loop may call this once per row.

ADMINISTRATOR ROLES ARE REFUSED HERE, AND THAT IS WHY THIS ROUTE CAN EXIST
AT ALL. The module docstring withholds every route that can end with a
person holding PropelAuth `Owner` or `Admin`. The reason is specific:
`build_api_principal` can answer AS an org administrator, and the app's
`_assert_can_grant_permissions` bypasses its non-escalation check for a
privileged org role - so a bare proxy of the app handler would let a
credential mint an administrator. Capping the role here removes exactly
that, and leaves the capability that was actually asked for: admitting
members and project roles in bulk from a script, a workflow or an agent.
Granting administration stays a decision for a person, in the product.

422 when neither or both of `role_id`/`role` are given; 404 when the role is
not this organisation's; 403 when the role is administrator-equivalent, or
when the app refuses the grant because the key's own permissions do not
cover the role's.

## Headers

- `Authorization` string, nullable
- `X-Target-Org-Id` string, nullable

## Request body

- InviteMemberRequest: Invite one person into the organisation. Mirrors `roles.InviteUserRequest` with ONE addition: the role may be named instead of identified by UUID. The app model takes `role_id: UUID` only, because its caller is a role picker that already holds the id. An API caller holds a NAME - "Engineer", "Member" - and making it fetch `GET /roles` first to translate is a round trip this contract can absorb. So exactly one of `role_id` or `role` is required; `role` is resolved against this organisation's own roles by slug first, then by case-insensitive display name. `role` is deliberately NOT defaulted to "Member". An absent role on an invitation is a caller mistake, and silently picking the fallback is how someone ends up in an organisation at a permission level nobody chose.
  - `email` string, required: Email address to invite. Matched case-insensitively against existing members and invitations.
  - `role_id` string, uuid, nullable: Id of the role to grant, from `GET /roles`. Mutually exclusive with `role`.
  - `role` string, nullable: Role slug or display name to grant, resolved within this organisation (slug first, then case-insensitive name). Mutually exclusive with `role_id`.
  - `list_ids` integer[], nullable: List ids to assign. Honoured for the `service_provider` role only, as in the app.

## Response `201`

Successful Response

- ApiResponseOrgPayload
  - `data` OrgPayload, required: An open envelope for a single role, team, member or account record. Open on purpose. The app's own response models on this surface grow with the product - `RoleResponse` gained `rep_limits` and `manages_role_slugs`, `TeamResponse` gained channel counts and a campaign count - and a pinned mirror would SILENTLY DROP whatever it had not caught up on. A dropped permission in a role read is the worst possible kind of stale: it reads as "they do not have it".
  - `pagination` PaginationMeta
    - `page` integer, required: Current page number (1-indexed)
    - `limit` integer, required: Items per page
    - `total` integer, required: Total number of items
    - `has_next` boolean, required: Whether there are more pages
    - `next_cursor` string, nullable: Opaque cursor for the next page; null when there are no more items. Pass it back as the `cursor` query param to fetch the following page without computing page numbers.
    - `total_available` integer, nullable: Search endpoints only: how many records really match, when known. Can be larger than `total`, which is capped at the 10,000 results that can be paged. For company search this is a distinct-company estimate. Null when unknown; absent outside search.
    - `total_is_lower_bound` boolean, nullable: Search endpoints only: true when more records match than `total` shows (the result set is larger than the 10,000 results that can be paged). Absent outside search.
    - `notes` string[], nullable: Search endpoints only: plain-language notes about the result, e.g. that more records match than can be paged, or that a reversed `between` range was put in order.

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/graph8/apis/graph8-developer-api.md) · [All operations](https://skmtc.dev/graph8/apis/graph8-developer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/graph8/apis/graph8-developer-api/revisions/2e7cfa0ca921?raw)
