Skip to content

Endpoints: Organizations, Teams & Invitations

Create an additional organization owned (role: owner) by the calling user. Requires a human session — API keys are already bound to one org and can’t create another. Also 403s if a platform admin has turned off registration.allow_public_org_creation, unless the caller is a superadmin — see Configuration Reference — registration.

Request body — application/json

{"name": "Acme Corp"}

Response 201

{"org_id": "8f2a...", "name": "Acme Corp", "role": "owner"}

The three built-in report templates and every analyzer’s built-in rules are seeded for the new org automatically, same as at registration.

StatusDescription
201Organization created
403Caller is an API key, not a human session; or public organization creation is disabled and the caller isn’t a superadmin

Fetch one organization’s info. Requires membership in that org.

Response 200

{
"org_id": "8f2a...",
"name": "Acme Corp",
"plan_tier": "free",
"quota": {"monthly_budget_usd": 50.0},
"report_link_ttl_hours": 24,
"invite_link_ttl_hours": 168,
"created_at": "2026-01-01T00:00:00Z"
}
StatusDescription
200Organization info
403Caller does not belong to org_id
404Organization not found

List every member of the org (name, email, role, org-scoped active status, joined date, and the names of any teams they’re on). Any member role can call this.

Response 200

{
"members": [
{
"user_id": "u1...", "email": "[email protected]", "name": "Ada Lovelace", "role": "owner",
"is_active": true, "account_active": true,
"created_at": "2026-01-01T00:00:00Z", "teams": []
},
{
"user_id": "u2...", "email": "[email protected]", "name": null, "role": "member",
"is_active": true, "account_active": true,
"created_at": "2026-01-02T00:00:00Z", "teams": ["Platform", "Security"]
}
]
}

name is null until the user sets one via PATCH /auth/me. is_active is org-scoped (see PATCH /orgs/{org_id}/members/{user_id} below) — false here doesn’t mean the account itself is deactivated everywhere, only in this org. account_active is the whole-account User.is_active — only a platform admin can change it (PATCH /admin/users/{user_id}), and false here blocks login entirely, in every org, unlike is_active. teams is populated from team_members, not stored on the membership itself — a member can be on any number of teams, or none.


Add an already-registered user to the org directly, at a given role. Requires admin role. To bring in someone who hasn’t registered yet, use POST /orgs/{org_id}/invites instead — this endpoint 404s if the email isn’t a known account.

Request body

{"email": "[email protected]", "role": "member"}

Response 201

{"user_id": "u2...", "org_id": "8f2a...", "role": "member"}
StatusDescription
201Member added
403Caller does not have admin (or owner) role
404No registered user with that email
409User is already a member of this org

Change a member’s role and/or org-scoped active status. Requires admin role. Both fields optional — send just the one you’re changing.

role can never be set to owner here — transfer ownership directly instead (same rule as invitation links and SSO’s default_role). is_active is org-scoped: deactivating a member here blocks their access to this org only, on their very next request (enforced in require_principal, not just at login) — it leaves their access to any other org they belong to, and their account-wide User.is_active, untouched. See PATCH /admin/users/{user_id} for the account-wide equivalent.

Cannot be used on the caller’s own membership, or to demote the org’s only remaining owner.

Request body

{"role": "admin", "is_active": false}

Response 200 — the updated membership, same shape as an entry in GET /orgs/{org_id}/members.

StatusDescription
200Membership updated
400Targets the caller’s own membership
403Caller does not have admin (or owner) role
404user_id is not a member of this org
409Would demote/deactivate the org’s only remaining owner
422role set to owner

Remove a member from the org entirely. Requires admin role. Same self/sole-owner guardrails as the PATCH above.

StatusDescription
204Member removed
400Targets the caller’s own membership
403Caller does not have admin (or owner) role
404user_id is not a member of this org
409Target is the org’s only remaining owner

Remove your own membership. Any role may leave — the deliberate counterpart to the admin-only DELETE above, which refuses to act on the caller’s own membership.

Requires a human session; an API key is bound to one org for its whole life and has no membership to give up.

Response 200

{"left_org_id": "8f2a...", "org_id": "3b17...", "role": "member"}

org_id/role are null when the org just left was the last one.

StatusDescription
200Left; session re-scoped to org_id (or to no org)
403API-key principal, or not a member of this org
404Path org_id isn’t the caller’s current org
409Caller is the org’s only remaining owner

An organization always needs an owner, so its only owner cannot leave: transfer ownership first, or have a platform admin delete the organization. A membership provisioned by SCIM or SSO can be left like any other, but the IdP re-creates it on the next sync — remove the user there instead.


POST /orgs/{org_id}/members/{user_id}/reset-password

Section titled “POST /orgs/{org_id}/members/{user_id}/reset-password”

Reset a member’s password. Requires admin role. Generates a new random password server-side and returns it once — same one-time-reveal pattern as POST /orgs/{org_id}/invites’s token and POST /auth/keys’s raw key. It is never stored in plaintext and cannot be retrieved again after this response; relay it to the member out of band. Not applicable to SSO-backed accounts.

Response 200

{"temporary_password": "kX9Qy2z..."}
StatusDescription
200Password reset — temporary_password shown once
403Caller does not have admin (or owner) role
404user_id is not a member of this org
400Target account authenticates via SSO

POST /orgs/{org_id}/members/{user_id}/reset-2fa

Section titled “POST /orgs/{org_id}/members/{user_id}/reset-2fa”

Disable a member’s two-factor authentication. Requires admin role. Clears the member’s TOTP secret and recovery codes — they’ll need to enroll again from scratch. Use when a member has lost their authenticator device and their recovery codes.

Response 200

{"detail": "Two-factor authentication reset"}
StatusDescription
2002FA reset
403Caller does not have admin (or owner) role
404user_id is not a member of this org

Org-owned, freely-editable named groupings of existing members — e.g. “Platform” or “Security”. Pure organizational grouping: a team has no access-control effect at all — every org member still sees the same org-wide resources, custom rules, and findings regardless of team. A user can belong to any number of teams. Requires admin role to create/rename/delete a team or add/remove its members; any member can view.

team_id is a slug derived from name at creation and is immutable afterward — same pattern as rule_id/category_id/template_id. No name-uniqueness is enforced: two teams can both be named “Security”, with ids security/security-2.

Requires admin role.

Request body

{"name": "Platform"}

Response 201

{
"team_id": "platform", "org_id": "8f2a...", "name": "Platform",
"created_by": "u1...", "created_at": "2026-01-01T00:00:00Z", "updated_at": null
}

Any member. Response: {"teams": [...]}, each shaped like the create response above.

Any member. 404 if not found.

Requires admin role. Partial update — currently just name (team_id is immutable).

Requires admin role. Cascades: every team_members row for this team is removed too — unlike a rule category (which has an “Uncategorized” fallback for orphaned references), a team has no equivalent fallback for its members, so cascading is the only sane option. Does not touch the members’ own org membership.

GET /orgs/{org_id}/teams/{team_id}/members

Section titled “GET /orgs/{org_id}/teams/{team_id}/members”

Any member. Response: {"members": [{"user_id", "email", "added_at"}, ...]}.

POST /orgs/{org_id}/teams/{team_id}/members

Section titled “POST /orgs/{org_id}/teams/{team_id}/members”

Requires admin role. user_id must already be a member of this org — see GET /orgs/{org_id}/members.

Request body

{"user_id": "u2..."}
StatusDescription
201Added
404Team not found, or user_id isn’t a member of this org
409User is already on this team

DELETE /orgs/{org_id}/teams/{team_id}/members/{user_id}

Section titled “DELETE /orgs/{org_id}/teams/{team_id}/members/{user_id}”

Requires admin role. Removes the user from this team only — their organization membership is untouched. 404 if they weren’t on this team.


Reusable, expiring, shareable org-join links — same capability-token design as report links above (generate_api_key() for the raw token, only its SHA-256 stored server-side, raw token returned exactly once). Unlike POST /orgs/{org_id}/members, the invitee doesn’t need an account yet — opening the link lets them register or log in and land directly in the org.

An invitation created without an email is open: a bearer link, redeemable by whoever holds it, discoverable by nobody. That was the only kind for a long time, and it is unchanged.

Passing an email makes it addressed, which changes two things:

  • Only that account can redeem it. Holding the link is no longer enough — POST /invite/{token}/accept 403s for anyone signed in as a different address.
  • The recipient can find it without the link. It appears on their GET /auth/me/invitations, which is the point: this deployment sends no email, so an open invite that never physically reaches someone may as well not exist.

Requires admin role. Mints a new invite, valid for the org’s invite_link_ttl_hours (default 168h/7 days, 1–8760 range — no dedicated PATCH to change it yet, unlike report links’ TTL).

Request body

{"role": "member", "email": "[email protected]"}

email is optional — see Open vs addressed above. It is stored lower-cased, and matched case-insensitively.

Response 201 — token/join_url shown once, never retrievable again

{
"invite_id": "3f4a5b6c-...",
"token": "xK9mZpQ...",
"join_url": "https://.../invite/xK9mZpQ...",
"role": "member",
"email": "[email protected]",
"created_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-08T00:00:00Z"
}

For an addressed invitation the link is a convenience, not the delivery mechanism — the recipient already has it on their own account.

StatusDescription
201Invite created — save the join_url now
403Caller does not have admin role
422role was owner

Requires admin role. Lists every invite ever created for the org, revoked or expired included (the dashboard grays those out rather than hiding them). Never includes the token.

Response 200

{
"invites": [
{
"invite_id": "3f4a5b6c-...",
"role": "member",
"created_by": "user-id-of-creator",
"created_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-08T00:00:00Z",
"revoked": false
}
]
}

Requires admin role. Stops the link from being used again — memberships already created through it are unaffected.

StatusDescription
204Invite revoked
403Caller does not have admin role
404Invite ID not found

Public, no authentication — lets an accept-invite page render “Join (org name) as (role)” before the visitor logs in or registers.

Response 200

{"org_name": "Acme Corp", "role": "member", "expires_at": "2026-01-08T00:00:00Z", "addressed": true}

addressed says whether the invitation names a specific email, so the page can warn that signing in as anyone else will be refused. A bool, never the address itself: this endpoint is unauthenticated, and whoever holds the link has not proved they are the intended recipient.

Deliberately omits org_id. 404 for not-found, expired, and revoked alike — same “never distinguish why” contract as GET /reports/{token}.

Requires an authenticated human session (not an API key). Joins the invite’s org at its role.

Response 200

{"org_id": "org-id", "role": "member"}
StatusDescription
200Joined (or already a member — role unchanged)
403The invitation is addressed to a different email
401No session, or an API-key principal
404Token unknown, expired, or revoked