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.
Accounts
Section titled “Accounts”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.
Organizations
Section titled “Organizations”| 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:
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.
Memberships
Section titled “Memberships”| 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.
Roles are data, not an enum
Section titled “Roles are data, not an enum”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 withisOwner: truerequires that permission, whichadmindeliberately 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.
The fourteen organization permissions
Section titled “The fourteen organization permissions”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.
Two namespaces in one array
Section titled “Two namespaces in one array”The permissions array you get back mixes two different kinds of string, and
treating them as one namespace will mislead you:
- Organization permissions — the 14
organization.*ids above. Granted by putting them in a role. - Resource capabilities — capability ids belonging to a resource server,
such as
wamp.ai.invoke(the metered AI proxy) and the twelvewamp.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.
Invitations
Section titled “Invitations”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:
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.
Redeeming one
Section titled “Redeeming one”Both redemption endpoints take the token in the request body, never in the URL.
# 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.
# 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"} |
Product access
Section titled “Product access”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 |
# Requires organization.products.readcurl -s "$API/api/organizations/8c1b…/products" -H "Authorization: Bearer $USER_ACCESS_TOKEN"
# Requires organization.products.managecurl -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.
Management endpoints
Section titled “Management endpoints”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.
Error codes on this plane
Section titled “Error codes on this plane”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.