Skip to content
Contents
Workspace data

Teams and invitations

Who is in a workspace, what they may do, and which channels they can see. Teams are an access boundary rather than an org chart: their only real power is confining a set of channels.

What is here

Members carry a ROLE, which resolves to permissions. Teams group members and may be confined to channels. Invitations are how somebody becomes a member. All of it hangs off a workspace.

Session credentials only

Every route in this section is declared for sessionToken alone. A machine credential has no team membership and no person to invite, so none of it is reachable with an API key. Most writes take team.manage; the reads take workspace.read.

Members and roles

GET/v1/workspaces/{workspaceId}/membersPATCH/v1/workspaces/{workspaceId}/members/{memberId}DELETE/v1/workspaces/{workspaceId}/members/{memberId}
ValueDescription
ownerEverything, including the things only an owner may do.
adminEverything except the owner-only actions.
managerRuns the inbox and the workspace's day-to-day, including analytics.
agentWorks conversations. Reads and uses, does not manage.
developerKeys, webhooks, templates and the API surface.
billingThe billing screens and nothing else.
read_onlyReads. Changes nothing.

A membership id is a mem_… and is NOT the user id the rest of the API reports for the same human — assigning a conversation takes the membership id, and the session does not carry it, so claiming a thread means looking yourself up in the member list first. Permissions resolved for the caller are on the workspace itself; see authentication.

Invitations

GET/v1/workspaces/{workspaceId}/invitationsPOST/v1/workspaces/{workspaceId}/invitationsDELETE/v1/workspaces/{workspaceId}/invitations/{invitationId}POST/v1/invitations/accept
curl
curl -X POST https://whats.azzamkh.sa/api/v1/workspaces/ws_.../invitations \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{ "email": "agent@example.com", "role": "agent" }'

# 201 Created
# {
#   "id": "inv_...",
#   "email": "agent@example.com",
#   "role": "agent",
#   "status": "pending",
#   "expires_at": "2026-08-21T09:00:00.000Z",
#   "created_at": "2026-08-14T09:00:00.000Z"
# }

# The invitee accepts with the token from the email. This route names no
# workspace and takes no scope — the token carries both.
curl -X POST https://whats.azzamkh.sa/api/v1/invitations/accept \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{ "token": "..." }'

# 200 OK
# { "workspace_id": "ws_...", "role": "agent", "already_member": false }
  • POST /v1/invitations/accept names no workspace and takes NO scope — the token carries both. It answers already_member rather than failing when the person is already in, so accepting twice is safe.
  • An invitation carries an expires_at and a status. Revoking is a delete on the invitation, and it answers the id and the new status.
  • Inviting is limited to 50 an hour per workspace, and accepting to 20 an hour per person.

Teams

GET/v1/workspaces/{workspaceId}/teamsPOST/v1/workspaces/{workspaceId}/teamsGET/v1/workspaces/{workspaceId}/teams/{teamId}PATCH/v1/workspaces/{workspaceId}/teams/{teamId}DELETE/v1/workspaces/{workspaceId}/teams/{teamId}GET/v1/workspaces/{workspaceId}/teams/{teamId}/membersPOST/v1/workspaces/{workspaceId}/teams/{teamId}/membersDELETE/v1/workspaces/{workspaceId}/teams/{teamId}/members/{memberId}
  • POST …/teams is idempotent on the workspace and name: it returns the EXISTING team rather than refusing, so there is no name-taken error to report.
  • Deleting a team, removing a member and removing a team member all answer removed — not deleted.
  • GET …/teams is cursor paginated. …/teams/:id/members and …/teams/:id/channels are NOT — they answer a plain data array.
  • A team member row carries both member_id and user_id. The member_id can be an empty string when the workspace membership row is gone, so do not key a list on it blindly.

Channel access

GET/v1/workspaces/{workspaceId}/teams/{teamId}/channelsPUT/v1/workspaces/{workspaceId}/teams/{teamId}/channels

Granting a channel RESTRICTS it

A channel nobody has granted is visible to everyone holding messages.read. The first grant confines it to the teams that hold it. So PUT …/channels with an empty array does not hide anything — it removes THIS team's grants, and the channel becomes workspace-wide again if no other team holds it. Conversation access is filtered by the same rule, on reads and writes alike.

curl
# Granting INVERTS the intuition: a channel nobody has confined is visible
# to everyone with messages.read, and the first grant confines it.
curl -X PUT https://whats.azzamkh.sa/api/v1/workspaces/ws_.../teams/tm_.../channels \
  -H "Authorization: Bearer <access token>" \
  -H "Content-Type: application/json" \
  -d '{ "channel_ids": ["ch_..."] }'

# 200 OK
# { "team_id": "tm_...", "channel_ids": ["ch_..."] }

# Sending [] does not hide anything: it removes THIS team's grants, and
# the channel becomes workspace-wide again if no other team holds it.

Errors that matter here

CodeHTTPWhen it happens
TEAM_NOT_FOUND404No team with that id in this workspace.
MEMBER_NOT_FOUND404No such membership.
INVITATION_NOT_FOUND404No such invitation, or it is no longer pending.
MEMBER_ALREADY_EXISTS409That person is already a member of this workspace.
OWNER_REQUIRED403Only the workspace owner may do that.

Every code and its status is on the errors page.