Skip to content

Organizations and roles

Every authority decision in WAMP resolves through an organization. This page gives you the model — accounts, organizations, memberships, roles, permissions — and the exact permission each management call requires, so you can tell in advance whether a call will answer 403 permission_denied.

The organization is the tenant. A user is not “in WAMP”; a user is a member of an organization, and the membership is what carries authority. Tokens, API keys, app installations, and AI usage all attach to an organization, never to a bare user.

A WAMP account is one human: id, email, name, an optional avatar. There is no self-service creation. POST /auth/register always answers 403 with {"success": false, "error": "Registration is currently closed"}.

The only path to a new account is accepting an organization invitation. The invitation carries both the account creation grant and the first membership, and the two commit in one transaction — you never end up with an account that belongs to nothing.

Email addresses are never verified. The email_verified claim exists in OIDC tokens and is hardcoded false; no verification flow exists anywhere in the platform.

Field Notes
id UUID. This is what every management route takes as :orgId
slug Short unique handle
name Display name, 1–120 characters
kind Organization category, BUSINESS by default
status ACTIVE or SUSPENDED. Every call into a suspended organization fails 403 organization_suspended

Organizations are created through an operator bootstrap path, not by signed-in users. A signed-in user with no membership anywhere cannot make themselves an organization; they have to be invited into one.

List the organizations you belong to:

Terminal window
curl -s "$API/api/organizations" -H "Authorization: Bearer $USER_ACCESS_TOKEN"
{
"success": true,
"organizations": [
{
"id": "8c1b…",
"slug": "acme",
"name": "Acme",
"kind": "BUSINESS",
"status": "ACTIVE",
"currentMembership": {
"id": "3d90…",
"status": "ACTIVE",
"roles": [{ "id": "", "key": "admin", "name": "Admin", "permissions": [""], "isOwner": false, "isDefault": false }],
"permissions": ["organization.profile.read", ""]
}
}
]
}

If the credential you present is already bound to one organization, this listing is filtered to that organization, and any :orgId in a path that does not match it answers 403 organization_context_mismatch. That guard is why you cannot use a token minted for one tenant to read another.

Field Notes
id The membership_id that appears in OIDC tokens
status ACTIVE, SUSPENDED, or REMOVED
user id, email, name, avatarUrl
roles The full role objects assigned to this membership
joinedAt, suspendedAt, removedAt Lifecycle timestamps

Only an ACTIVE membership grants anything. Authority is re-read from the database inside the transaction of every mutation, so suspending a member takes effect on their next call — there is no cache to wait out, and no session to invalidate separately.

A role is a row owned by the organization, not a fixed platform enum:

Field Notes
key ≤64 characters, unique in the organization
name ≤80 characters
description ≤240 characters, nullable
permissions Array of permission ids, ≤100 entries. Unrecognized strings are dropped rather than rejected
isOwner Protected. An owner role’s authority comes from this flag, not from its permission list
isDefault The role an invitation uses when it names none

Three roles are seeded when an organization is created:

key permissions isOwner isDefault
owner empty — authority comes from the flag true false
admin all 14 organization permissions except organization.ownership.manage false false
member organization.profile.read, organization.members.read, organization.applications.read, organization.products.read false true

You can rename them, change their permissions, add your own roles, and delete roles nobody uses. Two rules constrain that:

  • You cannot grant what you do not hold. Creating or editing a role whose permissions exceed your own answers 403 permission_denied. This is the privilege-escalation guard, and it applies to the role’s resulting effective permissions, not just the diff you sent.
  • Owner roles need organization.ownership.manage. Assigning or removing a role with isOwner: true requires that permission, which admin deliberately does not have. An organization also refuses to drop its last active owner (409 final_owner_required).

An owner receives all 14 permissions regardless of what their role lists.

These ids are a stable contract; handlers authorize against the id and never against a role key or display name.

Permission What holding it permits
organization.profile.read Read the organization record
organization.profile.manage Rename the organization
organization.members.read List members with their roles
organization.members.invite Create, list, rotate, and revoke invitations
organization.members.manage Change a member’s status and role assignments; remove a member
organization.ownership.manage Assign or remove owner roles, grant a role that itself carries this permission, and revoke an invitation that carries an owner role
organization.roles.read List roles and the permission catalogue
organization.roles.manage Create, edit, and delete roles
organization.applications.read List the organization’s apps
organization.applications.manage Create apps; register and revoke app keys; manage OAuth clients, webhook endpoints, and hosted accounts
organization.integrations.read See every AI-proxy API key in the organization, and read server integrations
organization.integrations.manage Revoke any organization API key, and manage server integrations
organization.products.read List installed apps
organization.products.manage Change an installation’s member access; revoke an installation

Without organization.integrations.read, an active member still sees the API keys they created themselves, and without organization.integrations.manage they can still revoke their own. The permission widens the view to the whole organization; it does not gate the feature.

The permissions array you get back mixes two different kinds of string, and treating them as one namespace will mislead you:

  1. Organization permissions — the 14 organization.* ids above. Granted by putting them in a role.
  2. Resource capabilities — capability ids belonging to a resource server, such as wamp.ai.invoke (the metered AI proxy) and the twelve wamp.cloud.* capabilities. These are not organization permissions.

A capability only enters the array once the organization holds a live self-grant on an installed product that offers it. Adding wamp.ai.invoke to a role’s permission list does nothing on its own — the filter drops any capability the organization is not entitled to, so the string silently disappears from effective authority. Conversely, revoking the installation removes the capability from every member immediately, even before the string is cleaned out of stored roles.

That is why POST /auth/api-keys can answer 403 permission_denied to a full organization owner: owners get all 14 organization permissions automatically, but wamp.ai.invoke still has to come from a product grant.

The wamp.cloud.* capability set is documented with the product that defines it, at docs.cloud.vampikez.fun. Capability ids are seeded rows rather than a code enum, so treat any list of them as belonging to a specific resource server rather than to the platform.

An invitation is a single-use bearer token. The raw token is returned once, at creation, and only its SHA-256 hash is stored — a lost invite link cannot be recovered, only rotated.

Create one:

Terminal window
curl -X POST "$API/api/organizations/8c1b…/invitations" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"email":"person@example.com","roleIds":["3f2a…"],"expiresInDays":7}'
{
"success": true,
"invitation": {
"id": "b41c…",
"organization": { "id": "8c1b…", "slug": "acme", "name": "Acme", "status": "ACTIVE" },
"email": "person@example.com",
"status": "PENDING",
"expiresAt": "2026-08-18T09:00:00.000Z",
"invitedByUserId": "",
"acceptedByUserId": null,
"acceptedAt": null,
"revokedAt": null,
"createdAt": "2026-08-11T09:00:00.000Z",
"updatedAt": "2026-08-11T09:00:00.000Z",
"roles": [{ "id": "3f2a…", "key": "member", "name": "Member", "permissions": [""], "isOwner": false, "isDefault": true }]
},
"acceptUrl": "https://account.example.com/invite#<raw token>"
}

Request fields: email (lowercased, ≤320 characters), optional roleIds (1–20 role UUIDs; falls back to the organization’s isDefault role, and fails with 409 default_role_required if there is none), optional expiresInDays (1–30, default 7 days).

The raw token sits in the URL fragment of acceptUrl. Browsers never send a fragment to servers, proxies, referrers, or access logs, which is what keeps the token out of infrastructure you do not control. Preserve that shape if you re-send the link yourself.

Statuses are PENDING, ACCEPTED, REVOKED, and EXPIRED.

Both redemption endpoints take the token in the request body, never in the URL.

Terminal window
# Anyone holding the token can inspect it. No authentication.
curl -X POST "$API/api/invitations/resolve" \
-H 'Content-Type: application/json' -d '{"token":"<raw token>"}'

The response adds one field the invitation record does not carry: accountExists, a boolean saying whether a WAMP account already exists for the invited email. That is what lets an interface choose between “set a password and join” and “sign in and join”. Resolving an expired invitation also transitions it to EXPIRED as a side effect.

Terminal window
# Accepting requires an authenticated session, and the emails must match.
curl -X POST "$API/api/invitations/accept" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d '{"token":"<raw token>"}'
# {"success":true,"organizationId":"8c1b…","membershipId":"3d90…"}

If the invited email has no account yet, the token is instead redeemed by registering — the recipient sets a name and a password of at least 10 characters, and the account, membership, and invitation consumption commit together. That endpoint is same-origin-restricted to the Account Center host, so it is driven by opening the invite link in a browser rather than by your code.

Rotating an invitation issues a new token and invalidates the old link (POST /api/organizations/:orgId/invitations/:invitationId/rotate, optional expiresInDays). Revoking it (DELETE …/invitations/:invitationId) ends it outright; revoking an invitation that carries an owner role additionally requires organization.ownership.manage.

Situation Status Body
Unknown or already-consumed token on resolve 404 {"success": false, "error": "invitation_not_found"}
Past expiresAt 410 {"success": false, "error": "invitation_expired"}
Signed-in email differs from the invited email 403 {"success": false, "error": "invitation_email_mismatch"}
Already accepted or revoked 409 {"success": false, "error": "invitation_not_pending"}
A pending invitation for that email already exists 409 {"success": false, "error": "invitation_already_pending"}
The email already has an account and a membership 409 {"success": false, "error": "invitation_email_taken"}

When an app is installed in an organization, the installation — not the app — carries the access rules.

Field Notes
id The installation_id that appears in tokens
app id, slug, name, status
status, revokedAt An installation must be ACTIVE with no revokedAt to grant anything
accessMode ALL_MEMBERS, or ASSIGNED_MEMBERS for a named subset
assignedMembershipIds The named subset, when accessMode is ASSIGNED_MEMBERS
Terminal window
# Requires organization.products.read
curl -s "$API/api/organizations/8c1b…/products" -H "Authorization: Bearer $USER_ACCESS_TOKEN"
# Requires organization.products.manage
curl -X PATCH "$API/api/organizations/8c1b…/products/<installationId>/access" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"accessMode":"ASSIGNED_MEMBERS","membershipIds":["3d90…"]}'

accessMode interacts directly with sign-in: a user whose membership is not in assignedMembershipIds cannot select that organization at the consent screen, and an existing grant for them stops resolving. Revoking the installation (DELETE …/products/:installationId, organization.products.manage) invalidates every token that depended on it at the next refresh.

All of these take a WAMP user session as Authorization: Bearer <token> and, if the credential is bound to an organization, require :orgId to be that organization.

Method and path Permission required
GET /api/organizations Membership only
GET /api/organizations/:orgId organization.profile.read
PATCH /api/organizations/:orgId organization.profile.manage
GET /api/organizations/:orgId/members organization.members.read
PATCH /api/organizations/:orgId/members/:membershipId organization.members.manage
DELETE /api/organizations/:orgId/members/:membershipId organization.members.manage
GET /api/organizations/:orgId/roles organization.roles.read
POST /api/organizations/:orgId/roles organization.roles.manage
PATCH /api/organizations/:orgId/roles/:roleId organization.roles.manage
DELETE /api/organizations/:orgId/roles/:roleId organization.roles.manage
GET /api/organizations/:orgId/invitations organization.members.invite
POST /api/organizations/:orgId/invitations organization.members.invite
POST /api/organizations/:orgId/invitations/:invitationId/rotate organization.members.invite
DELETE /api/organizations/:orgId/invitations/:invitationId organization.members.invite
GET /api/organizations/:orgId/products organization.products.read
PATCH /api/organizations/:orgId/products/:installationId/access organization.products.manage
DELETE /api/organizations/:orgId/products/:installationId organization.products.manage
POST /api/invitations/resolve None
POST /api/invitations/accept Any authenticated user

PATCH …/members/:membershipId takes {"status": "ACTIVE" | "SUSPENDED"}, {"roleIds": [...]}, or both; DELETE on the same path sets the membership to REMOVED and answers 204. Role creation takes {key, name, description?, permissions, isDefault?}; the update form is the same object minus key, partial, and must not be empty.

Listing invitations returns only PENDING ones, and it is gated on organization.members.invite rather than organization.members.read — reading the member list does not include reading who has been invited.

Bodies are {"success": false, "error": "<code>"}.

Code Status Meaning
organization_not_found 404 No active membership for you in that organization. It is deliberately indistinguishable from “does not exist”
organization_suspended 403 The organization is suspended
permission_denied 403 You hold an active membership but not the required permission, or you tried to grant more than you hold
organization_context_mismatch 403 Your credential is bound to a different organization than the path
member_not_found 404 Unknown membership id in this organization
member_removed 409 The membership is REMOVED
invalid_member_transition 409 The requested status change is not legal from the current one
final_owner_required 409 The change would leave the organization with no active owner
role_not_found 404 Unknown role id
role_key_taken 409 Another role in this organization already uses that key
invalid_role_key 400 The key is not acceptable
invalid_permissions 400 The permission list is not acceptable
protected_role 409 The role is protected — an owner role cannot be edited or deleted this way
default_role_required 409 An invitation named no roles and the organization has no default role
role_in_use 409 The role is still assigned to a membership
invalid_invitation_expiry 400 expiresAt is not in the future
installation_not_found 404 Unknown installation in this organization
invalid_installation_access 400 The access-mode change is not acceptable
internal_error 500 Unexpected failure

Invitation-specific codes are in the table under Redeeming one.